diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f75ebc8..6f6b402 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 40 - versionName = "1.0.39" + versionCode = 41 + versionName = "1.0.40" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { @@ -217,6 +217,7 @@ dependencies { implementation("androidx.browser:browser:1.8.0") implementation("io.legere:pdfiumandroid:2.0.0") + implementation("org.zwobble.mammoth:mammoth:1.4.2") } spotless { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1d2823c..b580b1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index ac6e093..13e9018 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -82,7 +82,7 @@ fun AppNavigation( } } } - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2 -> { + FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX -> { if (uiState.selectedEpubBook != null) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { navController.navigate(AppDestinations.EPUB_READER_ROUTE) { diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 29225d2..ea03e10 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -318,6 +318,7 @@ class FolderSyncWorker( return when { mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB + mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || name.endsWith(".docx", true) -> FileType.DOCX name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI name.endsWith(".md", true) -> FileType.MD name.endsWith(".txt", true) -> FileType.TXT diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 6da6802..dbaa575 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -608,7 +608,7 @@ fun RecentFileCard( val context = LocalContext.current val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder - FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder + FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder } val imageModel = remember(item.coverImagePath) { item.coverImagePath?.let { File(it) } ?: placeholder diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index ffdd5b4..29ec226 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -1297,7 +1297,7 @@ private fun LibraryListItem( val context = LocalContext.current val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder - FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder + FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder } val imageModel = remember(item.coverImagePath) { item.coverImagePath?.let { File(it) } ?: placeholder diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 54b7ff9..0f063bd 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -136,7 +136,7 @@ enum class AddBooksSource(val displayName: String) { } enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7 + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX } enum class RenderMode { @@ -161,9 +161,13 @@ data class Shelf(val name: String, val books: List) { } enum class SortOrder(val displayName: String) { - RECENT("Recent"), TITLE_ASC("Title A-Z"), AUTHOR_ASC("Author A-Z"), PERCENT_ASC("Percent complete 0-100"), PERCENT_DESC( - "Percent complete 100-0" - ) + RECENT("Recent"), + TITLE_ASC("Title A-Z"), + AUTHOR_ASC("Author A-Z"), + PERCENT_ASC("Percent complete 0-100"), + PERCENT_DESC("Percent complete 100-0"), + SIZE_ASC("Size (Smallest)"), + SIZE_DESC("Size (Biggest)") } enum class ReadStatusFilter(val displayName: String) { @@ -372,6 +376,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f } SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f } + SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize } + SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize } } } @@ -478,8 +484,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (filesToDelete.isNotEmpty()) { val ids = filesToDelete.map { it.bookId } ids.forEach { bookId -> - pdfTextRepository.clearBookText(bookId) - clearImportedFileCache(bookId) + cleanupBookDataLocally(bookId) try { val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}") if (cacheDir.exists()) cacheDir.deleteRecursively() @@ -838,14 +843,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("Deleting book permanently from reader: $bookId") - pdfTextRepository.clearBookText(bookId) - try { - val cacheDir = File(appContext.cacheDir, "imported_file_$bookId") - if (cacheDir.exists()) cacheDir.deleteRecursively() - } catch (e: Exception) { - Timber.e(e, "Failed to clear cache for $bookId") - } - + cleanupBookDataLocally(bookId) recentFilesRepository.deleteFilePermanently(listOf(bookId)) withContext(Dispatchers.Main) { @@ -1013,13 +1011,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L - val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" - result = "${name}_${size}" + if (uri.scheme == "file") { + uri.path?.let { + val file = File(it) + result = "${file.name}_${file.length()}" + } + } else { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L + val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" + result = "${name}_${size}" + } } } } catch (e: Exception) { @@ -1560,7 +1565,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(syncedFolders = currentFolders) } val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) - filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) } + filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) try { @@ -1671,8 +1676,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val idsToRemove = filesToRemove.map { it.bookId } idsToRemove.forEach { bookId -> - pdfTextRepository.clearBookText(bookId) - clearImportedFileCache(bookId) + cleanupBookDataLocally(bookId) } recentFilesRepository.deleteFilePermanently(idsToRemove) } @@ -1689,7 +1693,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val folders = _internalState.value.syncedFolders folders.forEach { folder -> val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) - filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) } + filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) try { @@ -1902,6 +1906,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { try { recentFilesRepository.clearAllLocalData() + clearBookCache() pdfTextRepository.clearAllText() pdfTextBoxRepository.clearAll() prefs.edit { remove(KEY_LAST_SYNC_TIMESTAMP) } @@ -2378,6 +2383,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio recentFilesRepository.getFileByBookId(bookId) == null } + val fileSize = withContext(Dispatchers.IO) { + try { + if (uri.scheme == "file") { + uri.path?.let { File(it).length() } ?: 0L + } else { + appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L + } else 0L + } ?: 0L + } + } catch (e: Exception) { + Timber.e(e, "Failed to get file size for $uri") + 0L + } + } + val existingItem = recentFilesRepository.getFileByBookId(bookId) val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( uri, appContext @@ -2388,7 +2411,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio var author: String? = null var bookForMetadata = epubBook - if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { + if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX)) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") @@ -2450,7 +2473,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val finalBookMetadata = bookForMetadata - if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { + if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) && finalBookMetadata != null) { title = finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName @@ -2529,7 +2552,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio lastModifiedTimestamp = newLastModifiedTimestamp, isDeleted = false, isRecent = isRecent, - sourceFolderUri = sourceFolderUri + sourceFolderUri = sourceFolderUri, + fileSize = fileSize ) recentFilesRepository.addRecentFile(newItem) Timber.i("Added/Updated $displayName ($type) to recent files via repository.") @@ -2871,6 +2895,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private suspend fun cleanupBookDataLocally(bookId: String) { + pdfTextRepository.clearBookText(bookId) + clearImportedFileCache(bookId) + bookCacheDao.deleteEntireBookCache(bookId) + } + private fun clearImportedFileCache(bookId: String) { try { val cacheDir = File(appContext.cacheDir, "imported_file_$bookId") @@ -2892,15 +2922,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (uri.scheme != "opds-pse") { try { - val cursor = appContext.contentResolver.query(uri, null, null, null, null) - cursor?.use { - if (it.moveToFirst()) { - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L - val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown" - Timber.tag("FileOpenPerf") - .d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") + if (uri.scheme == "file") { + uri.path?.let { + val file = File(it) + val size = file.length() + val name = file.name + Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") + } + } else { + val cursor = appContext.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L + val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown" + Timber.tag("FileOpenPerf") + .d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") + } } } } catch (e: Exception) { @@ -2952,7 +2991,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio sourceFolderUri = null ) } - } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { + } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) if (recentItem?.sourceFolderUri != null) { @@ -3110,6 +3149,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") return when (mimeType) { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> { if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null } @@ -3181,6 +3221,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ".htm", ignoreCase = true ) == true -> FileType.HTML + fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX else -> null } @@ -3846,7 +3887,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio folderBooks.forEach { item -> idsToDeleteLocally.add(item.bookId) - pdfTextRepository.clearBookText(item.bookId) + cleanupBookDataLocally(item.bookId) clearImportedFileCache(item.bookId) @@ -3912,8 +3953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio for (item in managedBooks) { recentFilesRepository.markAsDeleted(listOf(item.bookId)) - pdfTextRepository.clearBookText(item.bookId) - clearImportedFileCache(item.bookId) + cleanupBookDataLocally(item.bookId) firestoreRepository.syncBookMetadata( currentUser.uid, @@ -3941,8 +3981,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.e(e, "Error during permanent deletion") recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) managedBooks.forEach { item -> - clearImportedFileCache(item.bookId) - pdfTextRepository.clearBookText(item.bookId) + cleanupBookDataLocally(item.bookId) } _internalState.update { it.copy( @@ -3954,8 +3993,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) managedBooks.forEach { item -> - clearImportedFileCache(item.bookId) - pdfTextRepository.clearBookText(item.bookId) + cleanupBookDataLocally(item.bookId) } } } @@ -4073,8 +4111,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val reflowBookIds = reflowBooks.map { it.bookId } reflowBookIds.forEach { bookId -> - clearImportedFileCache(bookId) - pdfTextRepository.clearBookText(bookId) + cleanupBookDataLocally(bookId) } recentFilesRepository.deleteFilePermanently(reflowBookIds) diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index b89c18d..f663779 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -61,6 +61,22 @@ class MetadataExtractionWorker( var title: String? = null var author: String? = null + val fileSize = try { + if (uri.scheme == "file") { + uri.path?.let { File(it).length() } ?: 0L + } else { + appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L + } else 0L + } ?: 0L + } + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}") + 0L + } + appContext.contentResolver.openInputStream(uri)?.use { inputStream -> when (type) { FileType.EPUB -> { @@ -99,15 +115,15 @@ class MetadataExtractionWorker( } } - if (coverPath != null || title != null || author != null) { + if (coverPath != null || title != null || author != null || fileSize > 0L) { val updatedItem = item.copy( coverImagePath = coverPath ?: item.coverImagePath, title = title ?: item.title ?: item.displayName, - author = author ?: item.author + author = author ?: item.author, + fileSize = if (fileSize > 0L) fileSize else item.fileSize ) recentFilesRepository.addRecentFile(updatedItem) - Timber.tag("MetadataWorker").d("Updated local metadata for: ${item.displayName}") - + Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)") } } catch (e: Exception) { diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 48b602e..d984533 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -101,6 +101,8 @@ import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.log10 +import kotlin.math.pow internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html" internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html" @@ -123,6 +125,13 @@ class CustomTabUriHandler(private val context: Context) : UriHandler { } } +fun formatFileSize(bytes: Long): String { + if (bytes <= 0) return "Unknown" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() + return String.format(Locale.US, "%.2f %s", bytes / 1024.0.pow(digitGroups.toDouble()), units[digitGroups]) +} + @Composable fun LegalText( modifier: Modifier = Modifier, @@ -446,6 +455,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S InfoRowDetailed("Author", it) } InfoRowDetailed("Format", item.type.name) + InfoRowDetailed("Size", formatFileSize(item.fileSize)) InfoRowDetailed("Added", formattedDate) InfoRowDetailed( label = "Location", diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt index d716bfa..da594b6 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -27,7 +27,7 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 15, exportSchema = false) +@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 16, exportSchema = false) @TypeConverters(FileTypeConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun recentFileDao(): RecentFileDao @@ -185,6 +185,12 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN fileSize INTEGER NOT NULL DEFAULT 0") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -196,7 +202,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, - MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15 + MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16 ) .fallbackToDestructiveMigration(false) .build() diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index 5cfea27..753cbee 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -50,5 +50,6 @@ data class RecentFileEntity( @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "NULL") val customName: String?, - @ColumnInfo(defaultValue = "NULL") val highlights: String? + @ColumnInfo(defaultValue = "NULL") val highlights: String?, + @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index 66c9e13..5406bfb 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -46,7 +46,8 @@ data class RecentFileItem( val sourceFolderUri: String? = null, val isReflowPreferred: Boolean = false, val customName: String? = null, - val highlightsJson: String? = null + val highlightsJson: String? = null, + val fileSize: Long = 0L ) { fun getUri(): Uri? = uriString?.toUri() } @@ -75,7 +76,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { sourceFolderUri = this.sourceFolderUri, isReflowPreferred = this.isReflowPreferred, customName = this.customName, - highlightsJson = this.highlights + highlightsJson = this.highlights, + fileSize = this.fileSize ) } @@ -103,7 +105,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { sourceFolderUri = this.sourceFolderUri, isReflowPreferred = this.isReflowPreferred, customName = this.customName, - highlights = this.highlightsJson + highlights = this.highlightsJson, + fileSize = this.fileSize ) } 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 6927c40..997e32a 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -130,7 +130,8 @@ class RecentFilesRepository(private val context: Context) { isRecent = item.isRecent, isDeleted = item.isDeleted, sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, - highlights = item.highlightsJson ?: existingItem.highlights + highlights = item.highlightsJson ?: existingItem.highlights, + fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize ) } else { item.toRecentFileEntity() 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 45f387d..13b5b18 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jsoup.Jsoup +import org.zwobble.mammoth.DocumentConverter import timber.log.Timber import java.io.File import java.io.FileOutputStream @@ -55,6 +56,7 @@ class SingleFileImporter(private val context: Context) { FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent) FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent) FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId, parseContent) + FileType.DOCX -> parseDocx(inputStream, originalBookNameHint, bookId, parseContent) else -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent) } } @@ -548,6 +550,68 @@ class SingleFileImporter(private val context: Context) { return@withContext book } + private suspend fun parseDocx( + inputStream: InputStream, + originalBookNameHint: 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() + } + val metadataFile = File(extractionDir, "book_metadata.json") + + if (metadataFile.exists()) { + try { + val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook + } catch (e: Exception) { + Timber.e(e, "Failed to load cached DOCX, parsing again") + } + } + + val parseStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint") + + val converter = DocumentConverter() + val result = converter.convertToHtml(inputStream) + val htmlContent = result.value ?: "" + + Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms") + + val fullHtml = """ + + + + ${originalBookNameHint.substringBeforeLast(".")} + + + $htmlContent + + + """.trimIndent() + + // 4. Delegate to the already built HTML caching and chunking mechanisms! + return@withContext parseHtml(fullHtml.byteInputStream(), originalBookNameHint, bookId, parseContent) + } + private fun writeHtmlChapter( extractionDir: File, bookId: String, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 03ef3f5..7de0935 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -82,6 +82,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -155,6 +156,7 @@ fun EpubReaderTopBar( onOpenDeviceVoiceSettings: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, + onOpenVisualOptions: () -> Unit, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, modifier: Modifier = Modifier, onToggleReflow: (() -> Unit)? = null, @@ -346,6 +348,18 @@ fun EpubReaderTopBar( ) HorizontalDivider() + DropdownMenuItem( + text = { Text("Visual Options") }, + onClick = { + showMoreMenu = false + onOpenVisualOptions() + }, + leadingIcon = { + Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + HorizontalDivider() + DropdownMenuItem( text = { Text("Auto Scroll") }, enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, @@ -357,7 +371,6 @@ fun EpubReaderTopBar( HorizontalDivider() - // *** ADDITION START *** DropdownMenuItem( text = { Text("TTS Voice Settings") }, onClick = { 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 1df5d43..adc0a42 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -460,6 +460,7 @@ fun EpubReaderHost( val searchFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() } var isNavigatingToPosition by remember { mutableStateOf(false) } + var isSeamlessTransitioning by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } @@ -476,6 +477,11 @@ fun EpubReaderHost( val snackbarHostState = remember { SnackbarHostState() } + var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } + var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } + var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } + var showVisualOptionsSheet by remember { mutableStateOf(false) } + var volumeScrollEnabled by remember { mutableStateOf(loadVolumeScrollSetting(context)) } @@ -1354,7 +1360,8 @@ fun EpubReaderHost( showBars = showBars, initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars, initialSystemBarsBehavior = initialSystemBarsBehavior, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + systemUiMode = systemUiMode ) var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } @@ -1433,6 +1440,17 @@ fun EpubReaderHost( } } + val pageInfoBottomPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp, + label = "PageInfoBottomPadding" + ) + + val isPageInfoVisible = when (pageInfoMode) { + PageInfoMode.DEFAULT -> !showBars + PageInfoMode.SYNC -> showBars + PageInfoMode.HIDDEN -> false + } + LaunchedEffect(bookmarks, paginator) { paginator ?: return@LaunchedEffect val bookPaginator = paginator as? BookPaginator @@ -1974,11 +1992,37 @@ fun EpubReaderHost( snackbarHost = { SnackbarHost(snackbarHostState) }, contentWindowInsets = WindowInsets.statusBars, ) { scaffoldPaddingValues -> + val currentTopPadding = scaffoldPaddingValues.calculateTopPadding() + var stableTopPadding by remember { mutableStateOf(0.dp) } + if (currentTopPadding > stableTopPadding) { + stableTopPadding = currentTopPadding + } + + val effectiveTopPadding = if (currentRenderMode == RenderMode.PAGINATED) { + if (systemUiMode == SystemUiMode.HIDDEN) { + 0.dp + } else { + val insets = androidx.core.view.ViewCompat.getRootWindowInsets(view) + val ignoringVisibilityTopPx = insets?.getInsetsIgnoringVisibility(androidx.core.view.WindowInsetsCompat.Type.statusBars())?.top ?: 0 + val ignoringVisibilityTop = with(density) { ignoringVisibilityTopPx.toDp() } + + if (ignoringVisibilityTop > 0.dp) { + ignoringVisibilityTop + } else if (stableTopPadding > 0.dp) { + stableTopPadding + } else { + 24.dp + } + } + } else { + currentTopPadding + } + Box( modifier = Modifier .fillMaxSize() .background(effectiveBg) - .padding(scaffoldPaddingValues) + .padding(top = effectiveTopPadding) .focusRequester(containerFocusRequester) .focusable() .volumeScrollHandler( @@ -2031,10 +2075,16 @@ fun EpubReaderHost( ) { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + val contentBottomPadding = if (showBars || showFormatAdjustmentBars) { + 0.dp + } else { + if (pageInfoMode == PageInfoMode.DEFAULT) PAGE_INFO_BAR_HEIGHT else 0.dp + } + Box( modifier = Modifier .fillMaxSize() - .padding(bottom = if (showBars || showFormatAdjustmentBars) 0.dp else PAGE_INFO_BAR_HEIGHT) + .padding(bottom = contentBottomPadding) .padding(top = 16.dp, start = 16.dp, end = 16.dp) .testTag("ReaderContainer") ) { @@ -2049,12 +2099,16 @@ fun EpubReaderHost( AnimatedContent( targetState = currentChapterIndex, transitionSpec = { - if (targetState > initialState) { - (slideInVertically { height -> height } + fadeIn()) - .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + if (!pullToTurnEnabled) { + fadeIn(animationSpec = tween(150)) togetherWith fadeOut(animationSpec = tween(150)) } else { - (slideInVertically { height -> -height } + fadeIn()) - .togetherWith(slideOutVertically { height -> height } + fadeOut()) + if (targetState > initialState) { + (slideInVertically { height -> height } + fadeIn()) + .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + } else { + (slideInVertically { height -> -height } + fadeIn()) + .togetherWith(slideOutVertically { height -> height } + fadeOut()) + } } }, label = "ChapterChangeAnimation", @@ -2159,9 +2213,7 @@ fun EpubReaderHost( .mapNotNull { it.fragmentId } } - @Suppress("KotlinConstantConditions", - "ControlFlowWithEmptyBody" - ) + @Suppress("ControlFlowWithEmptyBody") ChapterWebView( key = chapterKeyForWebView, chapterTitle = chapterToRender.title, @@ -2292,19 +2344,48 @@ fun EpubReaderHost( } }, onOverScrollTop = { dragAmount -> - if (targetChapterIndex > 0) { - pullToPrevProgress = - dragAmount / dragThresholdPx + if (pullToTurnEnabled) { + if (targetChapterIndex > 0) { + pullToPrevProgress = dragAmount / dragThresholdPx + } + } else { + if (targetChapterIndex > 0 && dragAmount > 20f && !isSeamlessTransitioning) { + isSeamlessTransitioning = true + webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + scope.launch { + delay(20) + initialScrollTargetForChapter = ChapterScrollPosition.END + currentChapterIndex-- + if (showBars) showBars = false + delay(300) + isSeamlessTransitioning = false + } + } } }, onOverScrollBottom = { dragAmount -> - if (targetChapterIndex < chapters.size - 1) { - pullToNextProgress = - dragAmount / dragThresholdPx + if (pullToTurnEnabled) { + if (targetChapterIndex < chapters.size - 1) { + pullToNextProgress = dragAmount / dragThresholdPx + } + } else { + if (targetChapterIndex < chapters.size - 1 && dragAmount > 20f && !isSeamlessTransitioning) { + isSeamlessTransitioning = true + webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + scope.launch { + delay(20) + initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentChapterIndex++ + if (showBars) showBars = false + delay(300) + isSeamlessTransitioning = false + } + } } }, onReleaseOverScrollTop = { - if (targetChapterIndex > 0 && pullToPrevProgress >= 1.0f) { + if (pullToTurnEnabled && targetChapterIndex > 0 && pullToPrevProgress >= 1.0f) { Timber.d("Swipe-up triggered. Saving position before changing to previous chapter." ) webViewRefForTts?.evaluateJavascript( @@ -2324,7 +2405,7 @@ fun EpubReaderHost( pullToPrevProgress = 0f }, onReleaseOverScrollBottom = { - if (targetChapterIndex < chapters.size - 1 && pullToNextProgress >= 1.0f) { + if (pullToTurnEnabled && targetChapterIndex < chapters.size - 1 && pullToNextProgress >= 1.0f) { Timber.d("Swipe-down triggered. Saving position before changing to next chapter." ) webViewRefForTts?.evaluateJavascript( @@ -2672,7 +2753,7 @@ fun EpubReaderHost( } } - if (currentChapterIndex > 0) { + if (pullToTurnEnabled && currentChapterIndex > 0) { ChapterChangeIndicator( text = "Release for Previous Chapter", progress = pullToPrevProgress, @@ -2683,7 +2764,7 @@ fun EpubReaderHost( ) } - if (currentChapterIndex < chapters.size - 1) { + if (pullToTurnEnabled && currentChapterIndex < chapters.size - 1) { ChapterChangeIndicator( text = "Release for Next Chapter", progress = pullToNextProgress, @@ -2698,13 +2779,14 @@ fun EpubReaderHost( } RenderMode.PAGINATED -> { + val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .padding(bottom = PAGE_INFO_BAR_HEIGHT) + .padding(bottom = contentBottomPadding) .testTag("ReaderContainer") ) { - @Suppress("KotlinConstantConditions") PaginatedReaderScreen( book = epubBook, isDarkTheme = isDarkTheme, @@ -2992,7 +3074,7 @@ fun EpubReaderHost( // Page Info Bar (Vertical) AnimatedVisibility( - visible = renderMode == RenderMode.VERTICAL_SCROLL && !showBars, + visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), modifier = Modifier.align(Alignment.BottomCenter) @@ -3002,7 +3084,7 @@ fun EpubReaderHost( .fillMaxWidth() .height(PAGE_INFO_BAR_HEIGHT) .background(infoBarBgColor) - .padding(bottom = bottomPadding) + .padding(bottom = bottomPadding + pageInfoBottomPadding) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center ) { @@ -3035,7 +3117,7 @@ fun EpubReaderHost( // Page Info Bar (Paginated) AnimatedVisibility( - visible = renderMode == RenderMode.PAGINATED && paginator != null && !showBars && paginatedPagerState.pageCount > 0, + visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), modifier = Modifier.align(Alignment.BottomCenter) @@ -3045,7 +3127,7 @@ fun EpubReaderHost( .fillMaxWidth() .height(PAGE_INFO_BAR_HEIGHT) .background(infoBarBgColor) - .padding(bottom = bottomPadding) + .padding(bottom = bottomPadding + pageInfoBottomPadding) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center ) { @@ -3400,6 +3482,7 @@ fun EpubReaderHost( onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, + onOpenVisualOptions = { showVisualOptionsSheet = true }, onToggleReflow = if (onToggleReflow != null) { { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { @@ -3950,6 +4033,27 @@ fun EpubReaderHost( ) } + if (showVisualOptionsSheet) { + VisualOptionsSheet( + systemUiMode = systemUiMode, + onSystemUiModeChange = { + systemUiMode = it + saveSystemUiMode(context, it) + }, + pageInfoMode = pageInfoMode, + onPageInfoModeChange = { + pageInfoMode = it + savePageInfoMode(context, it) + }, + pullToTurnEnabled = pullToTurnEnabled, + onPullToTurnChange = { + pullToTurnEnabled = it + savePullToTurn(context, it) + }, + onDismiss = { showVisualOptionsSheet = false } + ) + } + if (showFontSelectionSheet) { ModalBottomSheet( onDismissRequest = { showFontSelectionSheet = false }, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index 5c746af..7be0148 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -91,6 +91,12 @@ import androidx.core.content.edit import com.aryan.reader.R import com.aryan.reader.data.CustomFontEntity import java.io.File +import androidx.compose.material3.Switch +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars const val SETTINGS_PREFS_NAME = "epub_reader_settings" private const val TEXT_ALIGN_KEY = "reader_text_align" @@ -100,6 +106,9 @@ private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed" private const val FONT_FAMILY_KEY = "reader_font_family" private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled" private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled" +private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode" +private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode" +private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled" const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_LINE_HEIGHT_VAL = 1.6f @@ -119,6 +128,18 @@ enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify") } +enum class SystemUiMode(val id: Int, val title: String) { + DEFAULT(0, "Always Show"), + SYNC(1, "Sync with Menus"), + HIDDEN(2, "Always Hide") +} + +enum class PageInfoMode(val id: Int, val title: String) { + DEFAULT(0, "Always Show"), + SYNC(1, "Sync with Menus"), + HIDDEN(2, "Always Hide") +} + data class FormatSettings( val fontSize: Float, val lineHeight: Float, @@ -165,6 +186,38 @@ fun saveLocalReaderSettings( } } +fun saveSystemUiMode(context: Context, mode: SystemUiMode) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putInt(SYSTEM_UI_MODE_KEY, mode.id) } +} + +fun loadSystemUiMode(context: Context): SystemUiMode { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val id = prefs.getInt(SYSTEM_UI_MODE_KEY, SystemUiMode.DEFAULT.id) + return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.DEFAULT +} + +fun savePageInfoMode(context: Context, mode: PageInfoMode) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putInt(PAGE_INFO_MODE_KEY, mode.id) } +} + +fun loadPageInfoMode(context: Context): PageInfoMode { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val id = prefs.getInt(PAGE_INFO_MODE_KEY, PageInfoMode.DEFAULT.id) + return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT +} + +fun savePullToTurn(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) } +} + +fun loadPullToTurn(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true) +} + fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -591,4 +644,128 @@ fun FontSelectionSheetContent( } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VisualOptionsSheet( + systemUiMode: SystemUiMode, + onSystemUiModeChange: (SystemUiMode) -> Unit, + pageInfoMode: PageInfoMode, + onPageInfoModeChange: (PageInfoMode) -> Unit, + pullToTurnEnabled: Boolean, + onPullToTurnChange: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Visual Options", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + Spacer(modifier = Modifier.height(16.dp)) + + // System UI + Text("System UI (Status & Navigation Bars)", style = MaterialTheme.typography.titleMedium) + Text("Control the visibility of the device's system bars.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(12.dp)) + OptionSegmentedControl( + options = SystemUiMode.entries, + selectedOption = systemUiMode, + onOptionSelected = onSystemUiModeChange, + getLabel = { it.title } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Progress Bar + Text("Progress Bar", style = MaterialTheme.typography.titleMedium) + Text("The reading progress and chapter indicator at the bottom of the screen.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(12.dp)) + OptionSegmentedControl( + options = PageInfoMode.entries, + selectedOption = pageInfoMode, + onOptionSelected = onPageInfoModeChange, + getLabel = { it.title } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Pull to change chapter + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .clickable { onPullToTurnChange(!pullToTurnEnabled) } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Seamless Chapter Transition", style = MaterialTheme.typography.titleMedium) + Text("Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) }) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +fun OptionSegmentedControl( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + getLabel: (T) -> String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + options.forEach { option -> + val isSelected = option == selectedOption + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onOptionSelected(option) } + .padding(vertical = 10.dp, horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = getLabel(option), + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } } \ No newline at end of file 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 e61053e..0164118 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt @@ -42,7 +42,8 @@ fun EpubReaderSystemUiController( showBars: Boolean, initialIsAppearanceLightStatusBars: Boolean, initialSystemBarsBehavior: Int, - isDarkTheme: Boolean + isDarkTheme: Boolean, + systemUiMode: SystemUiMode ) { DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) { if (window == null) { @@ -53,13 +54,12 @@ fun EpubReaderSystemUiController( Timber.d("Applying immersive mode.") WindowCompat.setDecorFitsSystemWindows(window, false) - insetsController.hide(WindowInsetsCompat.Type.navigationBars()) insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE onDispose { Timber.d("Restoring system UI.") WindowCompat.setDecorFitsSystemWindows(window, true) - insetsController.show(WindowInsetsCompat.Type.navigationBars()) + insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars()) insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars insetsController.systemBarsBehavior = initialSystemBarsBehavior } @@ -72,13 +72,25 @@ fun EpubReaderSystemUiController( } } - LaunchedEffect(showBars, window, view) { + LaunchedEffect(showBars, systemUiMode, window, view) { if (window != null) { val insetsController = WindowCompat.getInsetsController(window, view) - if (showBars) { - insetsController.show(WindowInsetsCompat.Type.navigationBars()) - } else { - insetsController.hide(WindowInsetsCompat.Type.navigationBars()) + when (systemUiMode) { + SystemUiMode.DEFAULT -> { + insetsController.show(WindowInsetsCompat.Type.statusBars()) + if (showBars) insetsController.show(WindowInsetsCompat.Type.navigationBars()) + else insetsController.hide(WindowInsetsCompat.Type.navigationBars()) + } + SystemUiMode.SYNC -> { + if (showBars) { + insetsController.show(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) + } else { + insetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) + } + } + SystemUiMode.HIDDEN -> { + insetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) + } } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt index ca5bd32..36659a5 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt @@ -148,10 +148,29 @@ abstract class BookCacheDao { @Query("DELETE FROM processed_chapter_metadata") protected abstract suspend fun deleteAllChapterMetadata() + @Query("DELETE FROM configuration_cache WHERE bookId = :bookId") + abstract suspend fun deleteConfigurationCacheForBook(bookId: String) + + @Transaction + open suspend fun deleteEntireBookCache(bookId: String) { + deleteBook(bookId) + deleteChaptersForBook(bookId) + deleteAnchorsForBook(bookId) + deleteConfigurationCacheForBook(bookId) + } + + @Query("DELETE FROM anchor_index") + abstract suspend fun clearAnchors() + + @Query("DELETE FROM configuration_cache") + abstract suspend fun clearConfigurationCache() + @Transaction open suspend fun clearAllCache() { clearProcessedBooks() clearProcessedChapters() + clearAnchors() + clearConfigurationCache() } @Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash") 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 84c189a..1fb9fdf 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -5970,42 +5970,44 @@ fun PdfViewerScreen( } // AI feat - Box { - var showAiFeaturesMenu by remember { mutableStateOf(false) } - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = { showAiFeaturesMenu = true } - ) { - Icon( - painter = painterResource(id = R.drawable.ai), - contentDescription = "AI Features" - ) - } - DropdownMenu( - expanded = showAiFeaturesMenu, - onDismissRequest = { showAiFeaturesMenu = false }) { - DropdownMenuItem( - text = { - Text("Summarize Page (Page ${currentPage + 1})") - }, onClick = { - showAiFeaturesMenu = false - if (isProUser) { - showSummarizationPopup = true - coroutineScope.launch { - isSummarizationLoading = true - summarizationResult = null - summarizeCurrentPage(onUpdate = { result -> - summarizationResult = result - }, onFinish = { - isSummarizationLoading = false - }) + if (BuildConfig.FLAVOR != "oss") { + Box { + var showAiFeaturesMenu by remember { mutableStateOf(false) } + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = { showAiFeaturesMenu = true } + ) { + Icon( + painter = painterResource(id = R.drawable.ai), + contentDescription = "AI Features" + ) + } + DropdownMenu( + expanded = showAiFeaturesMenu, + onDismissRequest = { showAiFeaturesMenu = false }) { + DropdownMenuItem( + text = { + Text("Summarize Page (Page ${currentPage + 1})") + }, onClick = { + showAiFeaturesMenu = false + if (isProUser) { + showSummarizationPopup = true + coroutineScope.launch { + isAiDefinitionLoading = true + summarizationResult = null + summarizeCurrentPage(onUpdate = { result -> + summarizationResult = result + }, onFinish = { + isAiDefinitionLoading = false + }) + } + } else { + showSummarizationUpsellDialog = true } - } else { - showSummarizationUpsellDialog = true - } - }, enabled = !isSummarizationLoading && pdfDocument != null - ) + }, enabled = !isSummarizationLoading && pdfDocument != null + ) + } } }