From 355664fbccbc5088c82cecc854042c610deed112 Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 30 Mar 2026 21:19:27 +0530 Subject: [PATCH] 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. --- app/src/main/java/com/aryan/reader/Common.kt | 7 +- .../java/com/aryan/reader/LibraryScreen.kt | 18 +- .../java/com/aryan/reader/MainViewModel.kt | 291 +++++++++++------- .../aryan/reader/MetadataExtractionWorker.kt | 19 +- .../java/com/aryan/reader/StorageTracker.kt | 50 +++ .../reader/data/RecentFilesRepository.kt | 35 ++- .../java/com/aryan/reader/epub/EpubParser.kt | 36 +-- .../java/com/aryan/reader/epub/Fb2Parser.kt | 21 +- .../java/com/aryan/reader/epub/MobiParser.kt | 71 +++-- .../aryan/reader/epub/SingleFileImporter.kt | 65 +++- .../aryan/reader/epubreader/ChapterWebView.kt | 89 +++--- .../reader/epubreader/EpubReaderScreen.kt | 7 +- .../reader/epubreader/EpubReaderSystem.kt | 9 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 55 ++-- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 7 +- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 2 +- 16 files changed, 502 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/StorageTracker.kt diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index c327736..a121a70 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -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}." diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 4f3195f..ab17b17 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -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, - 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, allRecentFiles: List, - onAddFolderClick: (android.net.Uri) -> Unit, + onAddFolderClick: () -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, onScanNowClick: () -> Unit, @@ -1482,21 +1480,13 @@ private fun FolderSyncScreen( ) { var editingFolder by remember { mutableStateOf(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() ) diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 0ee38ba..5c1d91a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -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() + val idsToDeleteLocally = mutableListOf() - 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) } } } diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index 58c8785..b89c18d 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -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}") + } } } diff --git a/app/src/main/java/com/aryan/reader/StorageTracker.kt b/app/src/main/java/com/aryan/reader/StorageTracker.kt new file mode 100644 index 0000000..5ed62bd --- /dev/null +++ b/app/src/main/java/com/aryan/reader/StorageTracker.kt @@ -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]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 1e8367f..6927c40 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -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.") diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index 2e4c979..af65937 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -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 { if (pageListElement == null) { Timber.d("No 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() 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) } } diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index 8c9b4d6..0c8028d 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -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 { diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt index ef83903..af7f138 100644 --- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt @@ -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/") } diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index bd2ed7c..45f387d 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -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() } diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 81a79b0..4fd31f8 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -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") diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 880d21d..1df5d43 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -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) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt index 39a3aab..e61053e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt @@ -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) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 6e5b223..8d038b2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -270,19 +270,21 @@ internal object PdfBitmapPool { private val pool = ConcurrentLinkedQueue() 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( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 2f90416..fa161ab 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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) { diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index 9fa6c7a..3b3fcc5 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -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.") }