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.") }