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
+ )
+ }
}
}