From 4c5cd20b21c33f3c6774d82164165d479126046b Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 26 Mar 2026 13:22:05 +0530 Subject: [PATCH] General improvments (#114) * Add "Keep Screen On" feature to PDF and Epub readers. * Add `rawLibraryFiles` to `MainViewModel` and improve file path resolution in `SharedComposables`. Specifically: - Updated `MainViewModel` state and UI logic to track and provide unfiltered library files. - Enhanced file path formatting in `SharedComposables` to better handle Document, Tree, and primary storage URIs. - Added scrollable support for long text values in `DetailItem` composable. - Passed `rawLibraryFiles` to `FolderSyncScreen` to ensure sync logic uses the base file list. * Update info bar background and text colors in EpubReaderScreen to match the selected theme. * Prevent accidental UI bar toggles immediately after scrolling in EPUB vertical mode. * feat: implement EPUB highlights sync and legacy migration - Implement Cloud and Local Folder sync for EPUB highlights. - Add highlight JSON serialization/deserialization helpers in EpubReaderAnnotations. - Implement automatic migration of highlights from SharedPreferences to the Database on book open. - Add cleanup logic to remove legacy SharedPreferences data after successful DB migration. - Update RecentFilesRepository to trigger local folder metadata sync when highlights are modified. - Update FolderSyncWorker to pull highlights and custom names from linked folder metadata. * Update intent filters --- app/src/main/AndroidManifest.xml | 111 ++++++++++++++---- .../java/com/aryan/reader/FolderSyncWorker.kt | 4 + .../java/com/aryan/reader/LibraryScreen.kt | 5 +- .../java/com/aryan/reader/MainViewModel.kt | 20 +++- .../com/aryan/reader/SharedComposables.kt | 43 +++++-- .../java/com/aryan/reader/data/AppDatabase.kt | 10 +- .../aryan/reader/data/FolderBookMetadata.kt | 10 +- .../com/aryan/reader/data/RecentFileDao.kt | 3 + .../com/aryan/reader/data/RecentFileEntity.kt | 3 +- .../com/aryan/reader/data/RecentFileItem.kt | 15 ++- .../reader/data/RecentFilesRepository.kt | 15 ++- .../epubreader/EpubReaderAnnotations.kt | 47 ++++++++ .../reader/epubreader/EpubReaderControls.kt | 12 ++ .../reader/epubreader/EpubReaderScreen.kt | 98 ++++++++++++---- .../aryan/reader/pdf/PdfToHtmlGenerator.kt | 50 +++++--- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 39 +++++- .../aryan/reader/data/FirestoreRepository.kt | 3 +- 17 files changed, 404 insertions(+), 84 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da02503..b74f8bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ - + @@ -64,7 +64,7 @@ - + @@ -74,7 +74,7 @@ - + @@ -84,13 +84,9 @@ - - - - - + @@ -99,14 +95,10 @@ - - - - - + @@ -114,14 +106,28 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -131,7 +137,72 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 7caf244..29225d2 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -152,6 +152,8 @@ class FolderSyncWorker( lastPositionCfi = remoteMeta.lastPositionCfi, progressPercentage = remoteMeta.progressPercentage, bookmarksJson = remoteMeta.bookmarksJson, + highlightsJson = remoteMeta.highlightsJson, + customName = remoteMeta.customName, locatorBlockIndex = remoteMeta.locatorBlockIndex, locatorCharOffset = remoteMeta.locatorCharOffset, lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, @@ -247,6 +249,8 @@ class FolderSyncWorker( lastPositionCfi = remoteMeta?.lastPositionCfi, progressPercentage = remoteMeta?.progressPercentage, bookmarksJson = remoteMeta?.bookmarksJson, + highlightsJson = remoteMeta?.highlightsJson, + customName = remoteMeta?.customName, locatorBlockIndex = remoteMeta?.locatorBlockIndex, locatorCharOffset = remoteMeta?.locatorCharOffset ) diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 68fc298..4f3195f 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -134,6 +134,7 @@ fun LibraryScreen( val isShelfContextualModeActive = selectedShelves.isNotEmpty() val sortOrder = uiState.sortOrder val shelves = uiState.shelves + val rawLibraryFiles = uiState.rawLibraryFiles val pagerState = rememberPagerState( initialPage = uiState.libraryScreenStartPage, pageCount = { 3 } @@ -234,6 +235,7 @@ fun LibraryScreen( Box(modifier = Modifier.fillMaxSize()) { LibraryScreenContent( recentFiles = uiState.allRecentFiles, + rawLibraryFiles = rawLibraryFiles, shelves = shelves, selectedItems = selectedItems, selectedShelves = selectedShelves, @@ -460,6 +462,7 @@ fun ShelfScreen( @Composable fun LibraryScreenContent( recentFiles: List, + rawLibraryFiles: List, shelves: List, selectedItems: Set, selectedShelves: Set, @@ -759,7 +762,7 @@ fun LibraryScreenContent( 2 -> { FolderSyncScreen( syncedFolders = syncedFolders, - allRecentFiles = recentFiles, + allRecentFiles = rawLibraryFiles, onAddFolderClick = onAddFolderClick, onRemoveFolderClick = onRemoveFolderClick, onEditFolderFiltersClick = onEditFolderFiltersClick, diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 515f02e..0ee38ba 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -192,6 +192,7 @@ data class ReaderScreenState( val initialLocator: Locator? = null, val initialCfi: String? = null, val initialBookmarksJson: String? = null, + val initialHighlightsJson: String? = null, val initialPageInBook: Int? = null, val shelves: List = emptyList(), val viewingShelfName: String? = null, @@ -225,6 +226,7 @@ data class ReaderScreenState( val reflowProgress: Float? = null, val recentFiles: List = emptyList(), val allRecentFiles: List = emptyList(), + val rawLibraryFiles: List = emptyList(), val pinnedHomeBookIds: Set = emptySet(), val pinnedLibraryBookIds: Set = emptySet(), val libraryFilters: LibraryFilters = LibraryFilters(), @@ -421,6 +423,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio internalState.copy( recentFiles = visibleRecentFiles, allRecentFiles = sortedLibraryFiles, + rawLibraryFiles = baseVisibleFiles, contextualActionItems = validContextualItems, shelves = allShelves, booksAvailableForAdding = booksAvailableForAdding @@ -2565,6 +2568,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun saveHighlights(bookId: String, highlightsJson: String) { + viewModelScope.launch { + val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri + if (currentBookUri != null) { + recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item -> + recentFilesRepository.updateHighlights(item.bookId, highlightsJson) + } + } else if (bookId.isNotBlank()) { + recentFilesRepository.updateHighlights(bookId, highlightsJson) + } + } + } + val reflowWorkInfo: Flow = WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) .map { list -> @@ -2646,6 +2662,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ), initialCfi = null, initialBookmarksJson = item.bookmarksJson, + initialHighlightsJson = item.highlightsJson, isLoading = false ) } @@ -2847,7 +2864,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio selectedEpubUri = uri, initialLocator = locator, initialCfi = recentItem?.lastPositionCfi, - initialBookmarksJson = recentItem?.bookmarksJson + initialBookmarksJson = recentItem?.bookmarksJson, + initialHighlightsJson = recentItem?.highlightsJson, ) } diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index a13c953..30a88bc 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -48,6 +48,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -333,24 +334,36 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp)) } - val pathText = remember(item.sourceFolderUri, item.displayName) { - if (item.sourceFolderUri != null) { + val context = LocalContext.current + val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) { + if (item.sourceFolderUri != null && item.uriString != null) { try { - val uri = item.sourceFolderUri.toUri() - val docId = android.provider.DocumentsContract.getTreeDocumentId(uri) + val uri = item.uriString.toUri() + val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) { + android.provider.DocumentsContract.getDocumentId(uri) + } else if (android.provider.DocumentsContract.isTreeUri(uri)) { + android.provider.DocumentsContract.getTreeDocumentId(uri) + } else { + Uri.decode(uri.toString()) + } + val split = docId.split(":") val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0] - val relativePath = if (split.size > 1) { + var relativePath = if (split.size > 1) { Uri.decode(split[1]).removeSuffix("/") } else "" + if (!relativePath.endsWith(item.displayName)) { + relativePath = if (relativePath.isEmpty()) item.displayName else "$relativePath/${item.displayName}" + } + val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else "" - "/$storageName$leadingSlash$relativePath/${item.displayName}" + "/$storageName$leadingSlash$relativePath" } catch (_: Exception) { - val decoded = Uri.decode(item.sourceFolderUri) + val decoded = Uri.decode(item.uriString) if (decoded.contains("primary:")) { - "/Internal storage/${decoded.substringAfter("primary:").removeSuffix("/")}/${item.displayName}" + "/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/${item.displayName}" } else { item.displayName } @@ -435,6 +448,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S label = "Location", value = pathText, maxLines = 4, + isScrollable = true, onCopy = { clipboardManager.setText(AnnotatedString(pathText)) } @@ -471,6 +485,7 @@ private fun InfoRowDetailed( label: String, value: String, maxLines: Int = 1, + isScrollable: Boolean = false, // ADD THIS onCopy: (() -> Unit)? = null ) { Row( @@ -486,15 +501,23 @@ private fun InfoRowDetailed( .width(85.dp) .padding(top = 2.dp) ) + + val scrollModifier = if (isScrollable) { + Modifier + .heightIn(max = 66.dp) + .verticalScroll(androidx.compose.foundation.rememberScrollState()) + } else Modifier + Text( text = value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, + maxLines = if (isScrollable) Int.MAX_VALUE else maxLines, + overflow = if (isScrollable) TextOverflow.Clip else TextOverflow.Ellipsis, modifier = Modifier .weight(1f) .padding(top = 2.dp) + .then(scrollModifier) ) if (onCopy != null) { IconButton( 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 146a9b2..d716bfa 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 = 14, exportSchema = false) +@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 15, exportSchema = false) @TypeConverters(FileTypeConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun recentFileDao(): RecentFileDao @@ -179,6 +179,12 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN highlights TEXT DEFAULT NULL") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -190,7 +196,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_12_13, MIGRATION_13_14, MIGRATION_14_15 ) .fallbackToDestructiveMigration(false) .build() diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt index 4358f19..81b3d19 100644 --- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt +++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt @@ -19,7 +19,8 @@ data class FolderBookMetadata( val bookmarksJson: String?, val locatorBlockIndex: Int?, val locatorCharOffset: Int?, - val customName: String? + val customName: String?, + val highlightsJson: String? ) { fun toJsonString(): String { val json = JSONObject() @@ -38,6 +39,7 @@ data class FolderBookMetadata( json.put("locatorBlockIndex", locatorBlockIndex ?: -1) json.put("locatorCharOffset", locatorCharOffset ?: -1) json.put("customName", customName) + json.put("highlightsJson", highlightsJson) return json.toString() } @@ -69,7 +71,8 @@ data class FolderBookMetadata( bookmarksJson = json.optStringNull("bookmarksJson"), locatorBlockIndex = json.optIntNull("locatorBlockIndex"), locatorCharOffset = json.optIntNull("locatorCharOffset"), - customName = json.optStringNull("customName") + customName = json.optStringNull("customName"), + highlightsJson = json.optStringNull("highlightsJson") ) } } @@ -97,6 +100,7 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, isDeleted = false, bookmarksJson = this.bookmarksJson, sourceFolderUri = sourceFolderUri, - customName = this.customName + customName = this.customName, + highlightsJson = this.highlightsJson ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 8e7a1b7..2ecbf18 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -93,4 +93,7 @@ interface RecentFileDao { @Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL") suspend fun detachAllFolderBooks() + + @Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") + suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long) } \ No newline at end of file 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 0ca11a3..5cfea27 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -49,5 +49,6 @@ data class RecentFileEntity( val bookmarks: String?, @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, - @ColumnInfo(defaultValue = "NULL") val customName: String? + @ColumnInfo(defaultValue = "NULL") val customName: String?, + @ColumnInfo(defaultValue = "NULL") val highlights: String? ) \ 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 65afffc..66c9e13 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -45,7 +45,8 @@ data class RecentFileItem( val bookmarksJson: String? = null, val sourceFolderUri: String? = null, val isReflowPreferred: Boolean = false, - val customName: String? = null + val customName: String? = null, + val highlightsJson: String? = null ) { fun getUri(): Uri? = uriString?.toUri() } @@ -73,7 +74,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { bookmarksJson = this.bookmarks, sourceFolderUri = this.sourceFolderUri, isReflowPreferred = this.isReflowPreferred, - customName = this.customName + customName = this.customName, + highlightsJson = this.highlights ) } @@ -100,7 +102,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { bookmarks = this.bookmarksJson, sourceFolderUri = this.sourceFolderUri, isReflowPreferred = this.isReflowPreferred, - customName = this.customName + customName = this.customName, + highlights = this.highlightsJson ) } @@ -122,7 +125,8 @@ fun RecentFileItem.toBookMetadata(): BookMetadata { lastModifiedTimestamp = this.lastModifiedTimestamp, bookmarksJson = this.bookmarksJson, hasAnnotations = false, - customName = this.customName + customName = this.customName, + highlightsJson = this.highlightsJson ) } @@ -147,6 +151,7 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem { lastModifiedTimestamp = this.lastModifiedTimestamp, isDeleted = this.isDeleted, bookmarksJson = this.bookmarksJson, - customName = this.customName + customName = this.customName, + highlightsJson = this.highlightsJson ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index a8e04d7..1e8367f 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -117,7 +117,8 @@ class RecentFilesRepository(private val context: Context) { progressPercentage = item.progressPercentage ?: existingItem.progressPercentage, isRecent = item.isRecent, isDeleted = item.isDeleted, - sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri + sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, + highlights = item.highlightsJson ?: existingItem.highlights ) } else { item.toRecentFileEntity() @@ -128,6 +129,12 @@ class RecentFilesRepository(private val context: Context) { Timber.d("Added/Updated recent file in DB: ${item.displayName}") } + suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) { + val currentTime = System.currentTimeMillis() + recentFileDao.updateHighlights(bookId, highlightsJson, currentTime) + Timber.d("Updated highlights for $bookId") + } + suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) { val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext val folderUriString = entity.sourceFolderUri @@ -135,7 +142,8 @@ class RecentFilesRepository(private val context: Context) { if (folderUriString != null) { val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f) val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]" - val isDirty = entity.isRecent || hasProgress || hasBookmarks + val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]" + val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights if (!isDirty) { Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.") @@ -159,7 +167,8 @@ class RecentFilesRepository(private val context: Context) { bookmarksJson = entity.bookmarks, locatorBlockIndex = entity.locatorBlockIndex, locatorCharOffset = entity.locatorCharOffset, - customName = entity.customName + customName = entity.customName, + highlightsJson = entity.highlights ) LocalSyncUtils.saveMetadataToFolder( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index b7766e0..a4da7f5 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -215,6 +215,53 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List { + if (jsonString.isNullOrBlank()) return emptyList() + val list = mutableListOf() + try { + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val colorId = obj.getString("colorId") + val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW + list.add( + UserHighlight( + id = obj.optString("id", java.util.UUID.randomUUID().toString()), + cfi = obj.getString("cfi"), + text = obj.getString("text"), + color = color, + chapterIndex = obj.getInt("chapterIndex") + ) + ) + } + } catch (e: Exception) { + Timber.e(e, "Error parsing highlights JSON") + } + return list +} + +fun highlightsToJson(highlights: List): String { + val jsonArray = JSONArray() + highlights.forEach { h -> + val obj = JSONObject().apply { + put("id", h.id) + put("cfi", h.cfi) + put("text", h.text) + put("colorId", h.color.id) + put("chapterIndex", h.chapterIndex) + } + jsonArray.put(obj) + } + return jsonArray.toString() +} + +fun clearHighlightsFromPrefs(context: Context, bookTitle: String) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "") + val key = "highlights_data_$sanitizedTitle" + prefs.edit { remove(key) } +} + // --- Logic Helpers --- fun processAndAddHighlight( 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 9f6ddd3..03ef3f5 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -142,6 +142,8 @@ fun EpubReaderTopBar( volumeScrollEnabled: Boolean, isPageTurnAnimationEnabled: Boolean, onNavigateBack: () -> Unit, + isKeepScreenOn: Boolean, + onToggleKeepScreenOn: (Boolean) -> Unit, onCloseSearch: () -> Unit, onChangeRenderMode: (RenderMode) -> Unit, onToggleBookmark: () -> Unit, @@ -334,6 +336,16 @@ fun EpubReaderTopBar( ) HorizontalDivider() + DropdownMenuItem( + text = { Text("Keep Screen On") }, + onClick = { + onToggleKeepScreenOn(!isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = "Enabled") } + ) + HorizontalDivider() + DropdownMenuItem( text = { Text("Auto Scroll") }, enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, 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 3ac9c1d..880d21d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -206,6 +206,17 @@ private const val AUTO_SCROLL_LOCAL_SPEED_PREFIX = "auto_scroll_local_speed_" private const val AUTO_SCROLL_LOCAL_MIN_PREFIX = "auto_scroll_local_min_" private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" +private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" + +private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) } +} + +private fun loadKeepScreenOn(context: Context): Boolean { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getBoolean(KEEP_SCREEN_ON_KEY, false) +} private fun saveMusicianMode(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -385,10 +396,16 @@ fun EpubReaderScreen( initialLocator = initialLocator, initialCfi = initialCfi, initialBookmarksJson = initialBookmarksJson, + initialHighlightsJson = uiState.initialHighlightsJson, isProUser = isProUser, onNavigateBack = onNavigateBack, onSavePosition = onSavePosition, onBookmarksChanged = onBookmarksChanged, + onHighlightsChanged = { json -> + uiState.selectedBookId?.let { id -> + viewModel.saveHighlights(id, json) + } + }, onNavigateToPro = onNavigateToPro, coverImagePath = coverImagePath, onRenderModeChange = onRenderModeChange, @@ -419,10 +436,12 @@ fun EpubReaderHost( initialLocator: Locator?, initialCfi: String?, initialBookmarksJson: String?, + initialHighlightsJson: String?, isProUser: Boolean, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, + onHighlightsChanged: (highlightsJson: String) -> Unit, onNavigateToPro: () -> Unit, coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, @@ -479,9 +498,22 @@ fun EpubReaderHost( ) } - val userHighlights = remember { + val userHighlights = remember(epubBook.title) { mutableStateListOf().apply { - addAll(loadHighlightsFromPrefs(context, epubBook.title)) + if (initialHighlightsJson != null) { + addAll(parseHighlightsJson(initialHighlightsJson)) + } else { + addAll(loadHighlightsFromPrefs(context, epubBook.title)) + } + } + } + + LaunchedEffect(userHighlights.size, userHighlights.toList()) { + val json = highlightsToJson(userHighlights) + onHighlightsChanged(json) + + if (initialHighlightsJson == null && userHighlights.isNotEmpty()) { + clearHighlightsFromPrefs(context, epubBook.title) } } @@ -586,10 +618,6 @@ fun EpubReaderHost( } } - LaunchedEffect(userHighlights.size, userHighlights.toList()) { - saveHighlightsToPrefs(context, epubBook.title, userHighlights) - } - // Dictionary var showAiDefinitionPopup by remember { mutableStateOf(false) } var selectedTextForAi by remember { mutableStateOf(null) } @@ -735,6 +763,7 @@ fun EpubReaderHost( var searchHighlightTarget by remember { mutableStateOf(null) } var lastHighlightClickTime by remember { mutableLongStateOf(0L) } + var lastScrollHideTime by remember { mutableLongStateOf(0L) } var webViewRefForTts by remember { mutableStateOf(null) } @@ -932,6 +961,15 @@ fun EpubReaderHost( var isMusicianMode by remember { mutableStateOf(loadMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) } + var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } + + DisposableEffect(isKeepScreenOn) { + view.keepScreenOn = isKeepScreenOn + onDispose { + view.keepScreenOn = false + } + } + DisposableEffect(Unit) { onDispose { Timber.d("Disposing sample MediaPlayer.") @@ -998,6 +1036,15 @@ fun EpubReaderHost( } val activeTextureId = activeTheme.textureId + val infoBarBgColor = remember(effectiveBg, isDarkTheme) { + val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f + val overlayColor = if (isDarkTheme) Color.White else Color.Black + val outR = overlayColor.red * overlayAlpha + effectiveBg.red * (1 - overlayAlpha) + val outG = overlayColor.green * overlayAlpha + effectiveBg.green * (1 - overlayAlpha) + val outB = overlayColor.blue * overlayAlpha + effectiveBg.blue * (1 - overlayAlpha) + Color(outR, outG, outB).copy(alpha = 0.95f) + } + val currentChapterInPaginatedMode by remember { derivedStateOf { if (currentRenderMode == RenderMode.PAGINATED) { @@ -2201,20 +2248,26 @@ fun EpubReaderHost( if (volumeScrollEnabled && !searchState.isSearchActive) { containerFocusRequester.requestFocus() } - if (showBars || showFormatAdjustmentBars) { - showBars = false - showFormatAdjustmentBars = false - Timber.d("Chapter tapped, hiding all bars.") + + if (System.currentTimeMillis() - lastScrollHideTime < 250) { + Timber.d("Ignoring tap toggle because bars were just hidden by scroll (sloppy tap).") } else { - showBars = true - Timber.d("Chapter tapped, showing main bars.") + if (showBars || showFormatAdjustmentBars) { + showBars = false + showFormatAdjustmentBars = false + Timber.d("Chapter tapped, hiding all bars.") + } else { + showBars = true + Timber.d("Chapter tapped, showing main bars.") + } } } }, onPotentialScroll = { - if (showBars) { + if (showBars || showFormatAdjustmentBars) { showBars = false showFormatAdjustmentBars = false + lastScrollHideTime = System.currentTimeMillis() // Added Timber.d("Scroll/Drag detected, hiding bars.") } if (isAutoScrollModeActive && isAutoScrollPlaying) { @@ -2947,7 +3000,7 @@ fun EpubReaderHost( modifier = Modifier .fillMaxWidth() .height(PAGE_INFO_BAR_HEIGHT) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f)) + .background(infoBarBgColor) .padding(bottom = bottomPadding) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center @@ -2958,7 +3011,7 @@ fun EpubReaderHost( Text( text = "$chapterTitle ($currentPageInChapter/$totalPagesInCurrentChapter)", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -2971,7 +3024,7 @@ fun EpubReaderHost( Text( text = "%.1f%%".format(currentBookProgress), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) @@ -2990,7 +3043,7 @@ fun EpubReaderHost( modifier = Modifier .fillMaxWidth() .height(PAGE_INFO_BAR_HEIGHT) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f)) + .background(infoBarBgColor) .padding(bottom = bottomPadding) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center @@ -3019,7 +3072,7 @@ fun EpubReaderHost( Text( text = textToShow, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -3053,7 +3106,7 @@ fun EpubReaderHost( Text( text = "%.1f%%".format(displayProgress), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) @@ -3068,7 +3121,7 @@ fun EpubReaderHost( Text( text = "%.1f%%".format(percentage), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) @@ -3283,6 +3336,11 @@ fun EpubReaderHost( volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, onNavigateBack = { triggerSaveAndExit() }, + isKeepScreenOn = isKeepScreenOn, + onToggleKeepScreenOn = { enabled -> + isKeepScreenOn = enabled + saveKeepScreenOn(context, enabled) + }, onCloseSearch = { searchState.isSearchActive = false searchState.onQueryChange("") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt index d556cff..b10cbe9 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt @@ -5,9 +5,7 @@ import android.content.Context import android.graphics.Bitmap import android.net.Uri import android.util.Base64 -import io.legere.pdfiumandroid.PdfiumCore import io.legere.pdfiumandroid.suspend.PdfDocumentKt -import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -219,9 +217,17 @@ object PdfToHtmlGenerator { val c = rawText[i] val code = c.code - if (code == 0 || code == 13) continue + if (code == 0) continue + + if (c == '\r') { + commitLine() + continue + } if (c == '\n') { + if (i > 0 && rawText[i - 1] == '\r') { + continue + } commitLine() continue } @@ -292,7 +298,7 @@ object PdfToHtmlGenerator { } buildPageHtml(pageNumber, finalElements, headerFooterStrings) - } ?: buildEmptyPageSection(pageNumber) + } } ?: buildEmptyPageSection(pageNumber) } catch (e: Exception) { Timber.tag(TAG).w(e, "Error extracting page $pageIdx") @@ -339,9 +345,12 @@ object PdfToHtmlGenerator { var inParagraph = false var inUl = false var inOl = false + var inLi = false fun closeParagraph() { if (inParagraph) { sb.append("

\n"); inParagraph = false } } + fun closeLi() { if (inLi) { sb.append("\n"); inLi = false } } fun closeList() { + closeLi() if (inUl) { sb.append("\n"); inUl = false } if (inOl) { sb.append("\n"); inOl = false } } @@ -404,29 +413,40 @@ object PdfToHtmlGenerator { sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}\n") } isBullet -> { - closeParagraph() + closeParagraph(); closeLi() if (inOl) { sb.append("\n"); inOl = false } if (!inUl) { sb.append("
    \n"); inUl = true } val content = trimmed.removePrefix("•").removePrefix("▪").removePrefix("◦").removePrefix("–").removePrefix("- ").trim() - sb.append("
  • ${content.escapeHtml()}
  • \n") + sb.append("
  • ${content.escapeHtml()}") + inLi = true } numberedMatch -> { - closeParagraph() + closeParagraph(); closeLi() if (inUl) { sb.append("
\n"); inUl = false } if (!inOl) { sb.append("
    \n"); inOl = true } val content = trimmed.substringAfter(" ").trim() - sb.append("
  1. ${content.escapeHtml()}
  2. \n") + sb.append("
  3. ${content.escapeHtml()}") + inLi = true } shouldBreakParagraph -> { - closeList() - if (!inParagraph) { sb.append("

    "); inParagraph = true } - sb.append(renderSpans(line.spans)) - closeParagraph() + if (inLi) { + sb.append(" ").append(renderSpans(line.spans)) + closeLi() + } else { + closeList() + if (!inParagraph) { sb.append("

    "); inParagraph = true } + sb.append(renderSpans(line.spans)) + closeParagraph() + } } else -> { - closeList() - if (!inParagraph) { sb.append("

    "); inParagraph = true } else sb.append(" ") - sb.append(renderSpans(line.spans)) + if (inLi) { + sb.append(" ").append(renderSpans(line.spans)) + } else { + closeList() + if (!inParagraph) { sb.append("

    "); inParagraph = true } else sb.append(" ") + sb.append(renderSpans(line.spans)) + } } } } 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 27ef2cc..2f90416 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -347,6 +347,17 @@ private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private const val PDF_THEME_KEY = "pdf_reader_theme" +private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled" + +private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_KEEP_SCREEN_ON_KEY, isEnabled) } +} + +private fun loadKeepScreenOn(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_KEEP_SCREEN_ON_KEY, false) +} private fun savePdfThemeId(context: Context, themeId: String) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -1152,7 +1163,7 @@ fun PdfViewerScreen( ?: pdfUri.lastPathSegment ?: "Document.pdf" } } - + val view = LocalView.current var isDockDragging by remember { mutableStateOf(false) } var initialScrollDone by remember { mutableStateOf(false) } @@ -1167,6 +1178,14 @@ fun PdfViewerScreen( var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) } var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } var showTtsSettingsSheet by remember { mutableStateOf(false) } + var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } + + DisposableEffect(isKeepScreenOn) { + view.keepScreenOn = isKeepScreenOn + onDispose { + view.keepScreenOn = false + } + } var showDictionarySettingsSheet by remember { mutableStateOf(false) } var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(context)) } @@ -1338,7 +1357,6 @@ fun PdfViewerScreen( } } - val view = LocalView.current val window = (view.context as? Activity)?.window LaunchedEffect(isFullScreen) { if (window != null) { @@ -5423,6 +5441,23 @@ fun PdfViewerScreen( } }) HorizontalDivider() + DropdownMenuItem( + text = { Text("Keep Screen On") }, + onClick = { + isKeepScreenOn = !isKeepScreenOn + saveKeepScreenOn(context, isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { + if (isKeepScreenOn) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + } + ) + HorizontalDivider() DropdownMenuItem( text = { Text("Auto Scroll") }, enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, diff --git a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt index 437bbb6..717ed29 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt @@ -23,7 +23,8 @@ data class BookMetadata( val lastModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, - val customName: String? = null + val customName: String? = null, + val highlightsJson: String? = null ) data class DeviceItem(