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( items(
filteredVoices.size, filteredVoices.size,
key = { filteredVoices[it].name }) { index -> key = { "${filteredVoices[it].name}_$it" }) { index ->
val voice = filteredVoices[index] val voice = filteredVoices[index]
val isSelected = voice.name == savedVoiceName val isSelected = voice.name == savedVoiceName
val friendlyName = numberedVoiceNames[voice.name] val friendlyName = numberedVoiceNames[voice.name]
@ -1692,6 +1692,11 @@ fun DeviceVoiceSettingsSheet(
trailingContent = { trailingContent = {
IconButton(onClick = { IconButton(onClick = {
val params = android.os.Bundle() 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 ttsEngine?.voice = voice
val sampleText = val sampleText =
"This is a sample of ${voice.locale.displayLanguage}." "This is a sample of ${voice.locale.displayLanguage}."

View file

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

View file

@ -35,6 +35,7 @@ import android.provider.OpenableColumns
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialCancellationException
import kotlinx.coroutines.withTimeoutOrNull
import androidx.credentials.exceptions.NoCredentialException import androidx.credentials.exceptions.NoCredentialException
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
@ -607,6 +608,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
syncFolderMetadata() syncFolderMetadata()
} }
sweepOrphanedCache()
viewModelScope.launch { billingClientWrapper.initializeConnection() } viewModelScope.launch { billingClientWrapper.initializeConnection() }
viewModelScope.launch { viewModelScope.launch {
@ -1511,7 +1514,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
saveSyncedFoldersToPrefs(currentFolders) saveSyncedFoldersToPrefs(currentFolders)
_internalState.update { it.copy(syncedFolders = currentFolders) } _internalState.update { it.copy(syncedFolders = currentFolders) }
// Cleanup val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
try { try {
appContext.contentResolver.releasePersistableUriPermission( appContext.contentResolver.releasePersistableUriPermission(
@ -1638,6 +1643,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { viewModelScope.launch {
val folders = _internalState.value.syncedFolders val folders = _internalState.value.syncedFolders
folders.forEach { folder -> folders.forEach { folder ->
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
try { try {
appContext.contentResolver.releasePersistableUriPermission( appContext.contentResolver.releasePersistableUriPermission(
@ -1912,7 +1920,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (currentUser != null) { if (currentUser != null) {
val deviceId = getInstallationId() val deviceId = getInstallationId()
try { try {
withTimeoutOrNull(3000) {
firestoreRepository.deleteDevice(currentUser.uid, deviceId) firestoreRepository.deleteDevice(currentUser.uid, deviceId)
}
Timber.i("Device $deviceId unregistered on sign out.") Timber.i("Device $deviceId unregistered on sign out.")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to unregister device on sign out.") Timber.e(e, "Failed to unregister device on sign out.")
@ -2345,31 +2355,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FileType.EPUB -> { FileType.EPUB -> {
epubParser.createEpubBook( epubParser.createEpubBook(
inputStream = inputStream, inputStream = inputStream,
bookId = bookId,
originalBookNameHint = displayName, originalBookNameHint = displayName,
parseContent = false parseContent = false
) )
} }
FileType.MOBI -> { FileType.MOBI -> {
mobiParser.createMobiBook( mobiParser.createMobiBook(
inputStream = inputStream, inputStream = inputStream,
originalBookNameHint = displayName bookId = bookId,
originalBookNameHint = displayName,
parseContent = false
) )
} }
FileType.FB2 -> { FileType.FB2 -> {
fb2Parser.createFb2Book( fb2Parser.createFb2Book(
inputStream = inputStream, inputStream = inputStream,
originalBookNameHint = displayName bookId = bookId,
originalBookNameHint = displayName,
parseContent = false
) )
} }
else -> { else -> {
singleFileImporter.importSingleFile( singleFileImporter.importSingleFile(
inputStream, inputStream,
type, type,
originalBookNameHint = displayName, originalBookNameHint = displayName,
bookId = bookId bookId = bookId,
parseContent = false
) )
} }
} }
@ -2412,8 +2425,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
} }
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
var cacheFile: File? = null
try { 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) { withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { input -> appContext.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) } cacheFile.outputStream().use { output -> input.copyTo(output) }
@ -2440,6 +2454,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
archiveDoc.close() archiveDoc.close()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error generating CBZ cover") 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) } _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? { private fun getFileNameFromUri(uri: Uri, context: Context): String? {
var fileName: String? = null var fileName: String? = null
if (uri.scheme == "content") { if (uri.scheme == "content") {
@ -2906,7 +2965,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContext.contentResolver.openInputStream(uri).use { inputStream -> appContext.contentResolver.openInputStream(uri).use { inputStream ->
if (inputStream == null) throw Exception("Could not open input stream") if (inputStream == null) throw Exception("Could not open input stream")
fb2Parser.createFb2Book( fb2Parser.createFb2Book(
inputStream, inputStream = inputStream,
bookId = bookId,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.fb2" 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") throw Exception("Could not open input stream for URI")
} }
mobiParser.createMobiBook( mobiParser.createMobiBook(
inputStream, inputStream = inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( bookId = bookId,
uri, appContext originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.mobi"
) ?: "unknown.mobi"
) )
} }
} }
@ -3137,10 +3196,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
throw Exception("Could not open input stream for URI") throw Exception("Could not open input stream for URI")
} }
epubParser.createEpubBook( epubParser.createEpubBook(
inputStream, inputStream = inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( bookId = bookId,
uri, appContext originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub"
) ?: "unknown.epub"
) )
} }
} }
@ -3725,6 +3783,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null } val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null }
withContext(Dispatchers.IO) {
if (folderBooks.isNotEmpty()) { if (folderBooks.isNotEmpty()) {
Timber.d("Processing ${folderBooks.size} folder books for deletion.") Timber.d("Processing ${folderBooks.size} folder books for deletion.")
@ -3752,7 +3811,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
// 2. Try to delete the metadata JSON (.bookId.json)
if (item.sourceFolderUri != null) { if (item.sourceFolderUri != null) {
try { try {
val rootUri = item.sourceFolderUri.toUri() val rootUri = item.sourceFolderUri.toUri()
@ -3794,10 +3852,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
val deviceId = getInstallationId() val deviceId = getInstallationId()
val remoteFiles = withContext(Dispatchers.IO) { val remoteFiles = googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
.associateBy { it.name } .associateBy { it.name }
}
for (item in managedBooks) { for (item in managedBooks) {
recentFilesRepository.markAsDeleted(listOf(item.bookId)) recentFilesRepository.markAsDeleted(listOf(item.bookId))
@ -3848,6 +3904,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
} }
}
val totalRemoved = folderBooks.size + managedBooks.size val totalRemoved = folderBooks.size + managedBooks.size
_internalState.update { _internalState.update {

View file

@ -12,6 +12,7 @@ import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File
class MetadataExtractionWorker( class MetadataExtractionWorker(
private val appContext: Context, private val appContext: Context,
@ -65,6 +66,7 @@ class MetadataExtractionWorker(
FileType.EPUB -> { FileType.EPUB -> {
val book = epubParser.createEpubBook( val book = epubParser.createEpubBook(
inputStream = inputStream, inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
parseContent = false parseContent = false
) )
@ -73,7 +75,12 @@ class MetadataExtractionWorker(
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
} }
FileType.MOBI -> { 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 { book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" } title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) } author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
@ -105,6 +112,16 @@ class MetadataExtractionWorker(
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}") 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() coverCacheDir.mkdirs()
pdfHighlightRepository.clearAll() 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) { suspend fun addRecentFile(item: RecentFileItem) = withContext(Dispatchers.IO) {
@ -289,8 +301,14 @@ class RecentFilesRepository(private val context: Context) {
} }
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) { suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
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) recentFileDao.deleteFilesBySourceFolder(folderUriString)
} }
}
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) { suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString) val item = recentFileDao.getFileByUri(uriString)
@ -368,6 +386,19 @@ class RecentFilesRepository(private val context: Context) {
} catch (e: Exception) { } catch (e: Exception) {
Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}") 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 }) recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.") Timber.d("Permanently removed recent files from DB.")

View file

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

View file

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

View file

@ -125,7 +125,12 @@ class MobiParser(private val context: Context) {
return File(parentDir, bookIdentifier) 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) val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try { try {
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
@ -157,27 +162,27 @@ class MobiParser(private val context: Context) {
val bookTitle = parsedData.title ?: originalBookNameHint val bookTitle = parsedData.title ?: originalBookNameHint
val bookAuthor = parsedData.author ?: "Unknown Author" val bookAuthor = parsedData.author ?: "Unknown Author"
val bookIdentifier = bookTitle.asFileName() + "_" + UUID.randomUUID().toString().substring(0, 8) val extractionDir = File(context.cacheDir, "imported_file_$bookId")
val extractionDir = getBookExtractionDir(bookIdentifier)
extractionDir.mkdirs() 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 val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") } .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 } .mapIndexed { index, resource -> (index + 1) to resource.path }
.toMap() .toMap()
if (parseContent) {
parsedData.resources.forEach { resource -> parsedData.resources.forEach { resource ->
try { try {
val file = File(extractionDir, resource.path) val file = File(extractionDir, resource.path)
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
file.writeBytes(resource.data) file.writeBytes(resource.data)
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}, Type: ${resource.mediaType}, Size: ${resource.data.size}") Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Parser: FAILED to write resource to disk: ${resource.path}") Timber.e(e, "Parser: FAILED to write resource to disk: ${resource.path}")
} }
} }
}
val cssFlowMap = parsedData.resources val cssFlowMap = parsedData.resources
.filter { it.mediaType == "text/css" && it.path.startsWith("flow_") } .filter { it.mediaType == "text/css" && it.path.startsWith("flow_") }
@ -250,7 +255,8 @@ class MobiParser(private val context: Context) {
Timber.d("Successfully split content into ${chapterHtmlParts.size} chapters.") Timber.d("Successfully split content into ${chapterHtmlParts.size} chapters.")
val epubChapters = chapterHtmlParts.mapIndexedNotNull { index, (chapterHtml, title) -> val epubChapters = if (parseContent) {
chapterHtmlParts.mapIndexedNotNull { index, (chapterHtml, title) ->
try { try {
val rewrittenHtml = processChapterHtml(chapterHtml) val rewrittenHtml = processChapterHtml(chapterHtml)
val doc = Jsoup.parse(rewrittenHtml) val doc = Jsoup.parse(rewrittenHtml)
@ -270,6 +276,7 @@ class MobiParser(private val context: Context) {
null null
} }
} }
} else emptyList()
val images = parsedData.resources val images = parsedData.resources
.filter { it.mediaType.startsWith("image/") } .filter { it.mediaType.startsWith("image/") }

View file

@ -48,21 +48,38 @@ class SingleFileImporter(private val context: Context) {
inputStream: InputStream, inputStream: InputStream,
type: FileType, type: FileType,
originalBookNameHint: String, originalBookNameHint: String,
bookId: String bookId: String,
parseContent: Boolean = true
): EpubBook { ): EpubBook {
return when (type) { return when (type) {
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId) FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId) FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId) FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId, parseContent)
else -> parsePlainText(inputStream, originalBookNameHint, bookId) // Fallback else -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
} }
} }
private suspend fun parseMarkdown( private suspend fun parseMarkdown(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String, originalBookNameHint: String,
bookId: String bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) { ): 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 { val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@ -191,8 +208,24 @@ class SingleFileImporter(private val context: Context) {
private suspend fun parsePlainText( private suspend fun parsePlainText(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String, originalBookNameHint: String,
bookId: String bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) { ): 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 { val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@ -347,8 +380,24 @@ class SingleFileImporter(private val context: Context) {
private suspend fun parseHtml( private suspend fun parseHtml(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String, originalBookNameHint: String,
bookId: String bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) { ): 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 { val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }

View file

@ -490,18 +490,26 @@ fun ChapterWebView(
localWebViewRef = this localWebViewRef = this
onWebViewInstanceCreated(this) onWebViewInstanceCreated(this)
addJavascriptInterface( addJavascriptInterface(
PageInfoBridge(onScrollStateUpdate), "PageInfoReporter" PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
}, "PageInfoReporter"
) )
addJavascriptInterface( addJavascriptInterface(
ProgressJsBridge(onTopChunkUpdated), "ProgressReporter" ProgressJsBridge { chunkIndex ->
this.post { onTopChunkUpdated(chunkIndex) }
}, "ProgressReporter"
) )
addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge") addJavascriptInterface(ContentBridge { index ->
this.post { onChunkRequested(index) }
}, "ContentBridge")
addJavascriptInterface( addJavascriptInterface(
HighlightJsBridge( HighlightJsBridge(
onCreateCallback = onHighlightCreated, onCreateCallback = { cfi, text, colorId ->
this.post { onHighlightCreated(cfi, text, colorId) }
},
onClickCallback = { cfi, text, left, top, right, bottom -> onClickCallback = { cfi, text, left, top, right, bottom ->
this.post {
onHighlightClicked() onHighlightClicked()
val densityValue = density.density val densityValue = density.density
@ -529,11 +537,14 @@ fun ChapterWebView(
cfi = cfi, cfi = cfi,
isExistingHighlight = true isExistingHighlight = true
) )
}), "HighlightBridge") }
}
), "HighlightBridge"
)
addJavascriptInterface( addJavascriptInterface(
AutoScrollJsBridge { AutoScrollJsBridge {
onAutoScrollChapterEnd() this.post { onAutoScrollChapterEnd() }
}, "AutoScrollBridge" }, "AutoScrollBridge"
) )
@ -602,15 +613,17 @@ fun ChapterWebView(
} }
addJavascriptInterface( addJavascriptInterface(
CfiJsBridge( CfiJsBridge(
onCfiReady = { cfi -> currentOnCfiGenerated(cfi) }, onCfiReady = { cfi -> this.post { currentOnCfiGenerated(cfi) } },
onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) }, onCfiForBookmarkReady = { cfi -> this.post { currentOnBookmarkCfiGenerated(cfi) } },
onScrollFinishedCallback = { success -> onScrollFinishedCallback = { success ->
currentOnScrollFinished(success) this.post { currentOnScrollFinished(success) }
}), "CfiBridge") }
), "CfiBridge"
)
addJavascriptInterface( addJavascriptInterface(
SnippetJsBridge { cfi, snippet -> SnippetJsBridge { cfi, snippet ->
currentOnSnippetForBookmarkReady(cfi, snippet) this.post { currentOnSnippetForBookmarkReady(cfi, snippet) }
}, "SnippetBridge" }, "SnippetBridge"
) )
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")

View file

@ -779,7 +779,7 @@ fun EpubReaderHost(
var currentChapterIndex by rememberSaveable(epubBook.title) { var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf( 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, view = view,
showBars = showBars, showBars = showBars,
initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars, initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars,
initialSystemBarsBehavior = initialSystemBarsBehavior initialSystemBarsBehavior = initialSystemBarsBehavior,
isDarkTheme = isDarkTheme
) )
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
@ -2249,7 +2250,7 @@ fun EpubReaderHost(
containerFocusRequester.requestFocus() 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).") Timber.d("Ignoring tap toggle because bars were just hidden by scroll (sloppy tap).")
} else { } else {
if (showBars || showFormatAdjustmentBars) { if (showBars || showFormatAdjustmentBars) {

View file

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

View file

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

View file

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

View file

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