From b20ade9946b4ca88fba499267f2026d419457a43 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 15 May 2026 22:36:51 +0530 Subject: [PATCH] Desktop app (#308) * Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination --- .gitignore | 1 + .idea/androidTestResultsUserPreferences.xml | 594 ++ .../com/aryan/reader/AppNavigationTest.kt | 30 +- .../reader/paginatedreader/HtmlParserTest.kt | 35 +- app/src/main/AndroidManifest.xml | 12 +- app/src/main/assets/epub_reader.js | 145 +- app/src/main/cpp/pdfium_bridge.cpp | 148 +- .../aryan/reader/AndroidSettingsHubModels.kt | 49 + .../aryan/reader/AndroidSharedStateBridge.kt | 235 + .../java/com/aryan/reader/AppNavigation.kt | 20 +- .../main/java/com/aryan/reader/AppUiModels.kt | 33 +- .../java/com/aryan/reader/BookImporter.kt | 3 +- app/src/main/java/com/aryan/reader/Common.kt | 97 +- .../reader/EmbeddedEbookMetadataExtractor.kt | 728 ++ .../aryan/reader/EpubMetadataFileEditor.kt | 115 + .../java/com/aryan/reader/FileTypeResolver.kt | 152 +- .../java/com/aryan/reader/FolderSyncWorker.kt | 46 +- .../main/java/com/aryan/reader/HomeScreen.kt | 118 +- .../java/com/aryan/reader/LibraryModels.kt | 75 +- .../java/com/aryan/reader/LibraryScreen.kt | 144 +- .../com/aryan/reader/LibraryStateProjector.kt | 394 +- .../java/com/aryan/reader/MainActivity.kt | 3 - .../main/java/com/aryan/reader/MainScreen.kt | 5 +- .../java/com/aryan/reader/MainViewModel.kt | 866 +- .../aryan/reader/MetadataExtractionWorker.kt | 177 +- .../com/aryan/reader/NonReaderScreenModels.kt | 54 - .../main/java/com/aryan/reader/ProScreen.kt | 30 +- .../aryan/reader/PurchaseAccountObfuscator.kt | 22 + .../reader/ReaderPaginationPreferences.kt | 28 + .../aryan/reader/ReaderScreenOrientation.kt | 183 + .../java/com/aryan/reader/SettingsScreen.kt | 583 ++ .../com/aryan/reader/SharedComposables.kt | 896 +- .../com/aryan/reader/SharedModelMappers.kt | 242 +- .../java/com/aryan/reader/ThemedBookCover.kt | 191 + .../java/com/aryan/reader/data/AppDatabase.kt | 36 +- .../com/aryan/reader/data/BookMetadataEdit.kt | 9 + .../aryan/reader/data/FolderBookMetadata.kt | 42 +- .../com/aryan/reader/data/LocalSyncUtils.kt | 281 +- .../com/aryan/reader/data/PurchaseEntities.kt | 5 +- .../com/aryan/reader/data/RecentFileDao.kt | 154 +- .../com/aryan/reader/data/RecentFileEntity.kt | 17 +- .../com/aryan/reader/data/RecentFileItem.kt | 55 +- .../reader/data/RecentFilesRepository.kt | 250 +- .../java/com/aryan/reader/epub/EpubParser.kt | 188 +- .../aryan/reader/epubreader/ChapterWebView.kt | 90 +- .../reader/epubreader/EpubReaderControls.kt | 272 +- .../reader/epubreader/EpubReaderScreen.kt | 755 +- .../reader/epubreader/InteractiveWebView.kt | 356 +- .../reader/paginatedreader/BookPaginator.kt | 441 +- .../reader/paginatedreader/ContentStyler.kt | 8 + .../reader/paginatedreader/MathMLRenderer.kt | 64 +- .../reader/paginatedreader/PaginatedReader.kt | 392 +- .../reader/paginatedreader/ReaderLinkStyle.kt | 93 + .../paginatedreader/RenderThemeApplier.kt | 11 + .../StablePaginatedNavigation.kt | 23 + .../aryan/reader/pdf/MagnifierComposable.kt | 295 +- .../com/aryan/reader/pdf/PdfBubblePrefetch.kt | 24 + .../com/aryan/reader/pdf/PdfDocumentUtils.kt | 38 +- .../java/com/aryan/reader/pdf/PdfDrawer.kt | 13 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 614 +- .../com/aryan/reader/pdf/PdfPreferences.kt | 47 +- .../java/com/aryan/reader/pdf/PdfSearchUI.kt | 8 +- .../com/aryan/reader/pdf/PdfSettingsSheets.kt | 59 +- .../java/com/aryan/reader/pdf/PdfToolbars.kt | 205 +- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 292 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 494 +- .../com/aryan/reader/pdf/RichTextSystem.kt | 29 +- .../com/aryan/reader/pdf/UniversalDocument.kt | 16 +- .../reader/pdf/data/PdfAnnotationData.kt | 16 +- .../pdf/data/PdfAnnotationRepository.kt | 4 +- .../aryan/reader/pdf/data/PdfTextDatabase.kt | 5 +- .../reader/pdf/data/PdfTextRepository.kt | 84 +- .../com/aryan/reader/pptx/PptxDocument.kt | 2544 ++++++ .../aryan/reader/tts/TtsPlaybackManager.kt | 9 +- .../java/com/aryan/reader/tts/TtsService.kt | 191 +- app/src/main/res/values/strings.xml | 27 +- .../com/aryan/reader/BillingClientWrapper.kt | 17 +- .../aryan/reader/data/FirestoreRepository.kt | 13 +- app/src/oss/res/values/strings.xml | 4 +- .../reader/AndroidSettingsHubModelsTest.kt | 195 + .../reader/AndroidSharedStateBridgeTest.kt | 230 + .../EmbeddedEbookMetadataExtractorTest.kt | 215 + .../com/aryan/reader/FileTypeResolverTest.kt | 18 + .../aryan/reader/LibraryStateProjectorTest.kt | 40 +- .../com/aryan/reader/MainViewModelTest.kt | 86 +- .../aryan/reader/NonReaderScreenModelsTest.kt | 147 - .../reader/PurchaseAccountObfuscatorTest.kt | 27 + .../reader/ReaderScreenOrientationTest.kt | 128 + .../aryan/reader/SharedModelMappersTest.kt | 155 + .../reader/data/FileTypeConverterTest.kt | 21 + .../reader/data/FolderBookMetadataTest.kt | 44 +- .../RecentFileDaoMetadataExtractionTest.kt | 244 + ...ecentFileItemReadingPositionMappingTest.kt | 55 + ...FilesRepositoryReadingPositionMergeTest.kt | 79 +- .../aryan/reader/epub/EpubParserUnitTest.kt | 77 +- .../EpubReaderBridgeAndControlsTest.kt | 1 + .../com/aryan/reader/opds/OpdsParserTest.kt | 11 +- .../reader/paginatedreader/CfiUtilsTest.kt | 18 + .../paginatedreader/ContentStylerTest.kt | 44 + .../PaginatedHighlightMappingTest.kt | 83 + .../StablePaginatedNavigationTest.kt | 97 + .../aryan/reader/pdf/MagnifierGeometryTest.kt | 99 + .../com/aryan/reader/pdf/PdfBitmapPoolTest.kt | 39 + .../reader/pdf/PdfReaderCoreLogicTest.kt | 100 + .../reader/pdf/PdfReaderPreferencesTest.kt | 7 +- .../reader/pdf/PdfReaderSerializerTest.kt | 7 +- .../PdfReaderSettingsAndSharedModelsTest.kt | 16 + .../aryan/reader/pdf/PdfTextRepositoryTest.kt | 25 + .../aryan/reader/pdf/PdfZoomLockStateTest.kt | 131 + .../reader/pptx/PptxDocumentParserTest.kt | 312 + desktopApp/build.gradle.kts | 571 +- desktopApp/compose-desktop.pro | 32 + .../reader/desktop/DesktopAiByokStore.kt | 26 +- .../reader/desktop/DesktopBookImporter.kt | 104 + .../reader/desktop/DesktopBuildProfile.kt | 105 + .../reader/desktop/DesktopByokAiAdapter.kt | 6 +- .../reader/desktop/DesktopComicArchive.kt | 4 +- .../reader/desktop/DesktopCustomFontStore.kt | 10 +- .../reader/desktop/DesktopDiagnostics.kt | 16 + .../desktop/DesktopFolderMetadataExtractor.kt | 152 +- .../reader/desktop/DesktopFolderSyncLog.kt | 19 + .../desktop/DesktopGeminiCloudTtsAdapter.kt | 18 +- .../reader/desktop/DesktopLibraryDatabase.kt | 4 +- .../reader/desktop/DesktopLocalFolderSync.kt | 451 +- .../reader/desktop/DesktopOpdsCoverImage.kt | 106 + .../reader/desktop/DesktopOpdsRepository.kt | 138 +- .../aryan/reader/desktop/DesktopPdfTheme.kt | 30 + .../com/aryan/reader/desktop/DesktopPdfium.kt | 166 +- .../reader/desktop/DesktopPlatformPaths.kt | 146 + .../reader/desktop/DesktopStartupSplash.kt | 163 + .../com/aryan/reader/desktop/DesktopTtsLog.kt | 2 +- .../reader/desktop/DesktopWindowPolish.kt | 291 + .../reader/desktop/DesktopWindowStateStore.kt | 155 + .../com/aryan/reader/desktop/Launcher.kt | 6 + .../kotlin/com/aryan/reader/desktop/Main.kt | 8023 +++++++++++++---- .../src/desktopMain/resources/episteme.ico | Bin 0 -> 15057 bytes .../desktopMain/resources/episteme_icon.png | Bin 0 -> 12890 bytes .../reader/desktop/DesktopAiByokStoreTest.kt | 19 + .../reader/desktop/DesktopBookImporterTest.kt | 76 + .../reader/desktop/DesktopBuildProfileTest.kt | 89 + .../desktop/DesktopCustomFontStoreTest.kt | 15 + .../DesktopFolderMetadataExtractorTest.kt | 40 + .../desktop/DesktopLocalFolderSyncTest.kt | 108 + .../desktop/DesktopOpdsRepositoryTest.kt | 62 + .../reader/desktop/DesktopPdfThemeTest.kt | 52 + .../desktop/DesktopPlatformPathsTest.kt | 72 + .../desktop/DesktopReaderDefaultsTest.kt | 182 + .../reader/desktop/DesktopStartupTest.kt | 58 + .../reader/desktop/DesktopWindowPolishTest.kt | 46 + .../desktop/DesktopWindowStateStoreTest.kt | 37 + .../reader/SharedReaderDiagnostics.android.kt | 5 + .../ui/SharedReaderModalLayer.android.kt | 19 + .../com/aryan/reader/shared/AppActions.kt | 15 + .../com/aryan/reader/shared/AppModels.kt | 7 +- .../aryan/reader/shared/FileCapabilities.kt | 148 +- .../aryan/reader/shared/ImportContracts.kt | 123 + .../com/aryan/reader/shared/LibraryModels.kt | 18 +- .../aryan/reader/shared/LibraryProjector.kt | 49 +- .../reader/shared/LibraryStateProjector.kt | 62 +- .../aryan/reader/shared/LocalFolderSync.kt | 141 +- .../shared/ReaderAnnotationSerializer.kt | 23 +- .../aryan/reader/shared/ReaderExtrasModels.kt | 43 +- .../reader/shared/RepositoryContracts.kt | 3 +- .../aryan/reader/shared/SettingsHubModels.kt | 987 ++ .../reader/shared/SharedFeaturePolicy.kt | 22 + .../reader/shared/SharedLibrarySnapshot.kt | 144 +- .../com/aryan/reader/shared/SharedReducers.kt | 33 + .../reader/shared/opds/SharedOpdsModels.kt | 3 + .../reader/shared/opds/SharedOpdsUtilities.kt | 1 + .../reader/shared/pdf/PdfInteractionModels.kt | 73 + .../reader/shared/pdf/PdfReaderSession.kt | 74 +- .../reader/shared/pdf/PdfVerticalLayout.kt | 74 + .../pdf/SharedPdfAnnotationSidecarCodec.kt | 69 +- .../shared/pdf/SharedPdfInkRendering.kt | 3 + .../reader/shared/pdf/SharedPdfRichText.kt | 63 +- .../shared/pdf/SharedPdfTextAnnotations.kt | 70 +- .../reader/shared/reader/ReaderEngine.kt | 345 +- .../reader/ReaderHtmlDocumentBuilder.kt | 1790 +++- .../reader/shared/reader/ReaderJumpHistory.kt | 125 + .../reader/shared/reader/ReaderModels.kt | 196 +- .../shared/reader/SharedReaderDiagnostics.kt | 13 + .../reader/shared/ui/LocalBookCoverImage.kt | 2 +- .../reader/shared/ui/NonReaderLayoutModels.kt | 78 +- .../reader/shared/ui/NonReaderScreens.kt | 981 +- .../shared/ui/ReaderContentRenderPlan.kt | 48 + .../reader/shared/ui/ReaderMinimalSlider.kt | 119 + .../reader/shared/ui/ReaderWorkspaceModels.kt | 63 +- .../reader/shared/ui/ReaderWorkspaceShell.kt | 416 +- .../aryan/reader/shared/ui/SharedAppShell.kt | 187 +- .../shared/ui/SharedAppThemeSettings.kt | 311 +- .../reader/shared/ui/SharedLibraryDialogs.kt | 825 +- .../shared/ui/SharedNativePaginatedReader.kt | 2408 +++++ .../reader/shared/ui/SharedOpdsScreen.kt | 108 +- .../reader/shared/ui/SharedPdfAnnotationUi.kt | 560 +- .../reader/shared/ui/SharedReaderChrome.kt | 2780 ++++-- .../shared/ui/SharedReaderModalLayer.kt | 55 + .../shared/ui/SharedReaderScrollbars.kt | 440 + .../reader/shared/ui/SharedSettingsHub.kt | 723 ++ .../shared/ui/SharedStableTextFields.kt | 83 + .../reader/shared/ui/SharedUtilityScreens.kt | 35 +- .../shared/EpubAnnotationSerializerTest.kt | 2 + .../reader/shared/FileCapabilitiesTest.kt | 36 +- .../shared/LocalFolderSyncEngineTest.kt | 200 +- .../reader/shared/ReaderActionReducerTest.kt | 13 +- .../shared/ReaderDefaultSettingsStateTest.kt | 84 + .../reader/shared/ReaderExtrasModelsTest.kt | 29 +- .../reader/shared/SettingsHubModelsTest.kt | 146 + .../reader/shared/SharedImportPlannerTest.kt | 115 + .../shared/SharedLibraryProjectorTest.kt | 47 +- .../shared/SharedLibrarySnapshotJsonTest.kt | 112 + .../aryan/reader/shared/SharedReducersTest.kt | 33 + .../shared/opds/SharedOpdsCatalogsTest.kt | 11 + .../reader/shared/pdf/PdfReaderSessionTest.kt | 112 +- .../pdf/SharedPdfAnnotationSerializerTest.kt | 67 +- .../shared/pdf/SharedPdfRichTextTest.kt | 29 + .../pdf/SharedPdfTextAnnotationsTest.kt | 28 +- .../reader/shared/reader/ReaderEngineTest.kt | 245 + .../reader/ReaderHtmlDocumentBuilderTest.kt | 351 +- .../shared/reader/ReaderJumpHistoryTest.kt | 72 + .../shared/reader/ReaderSpreadLayoutTest.kt | 78 + .../shared/ui/NonReaderLayoutModelsTest.kt | 152 +- .../shared/ui/ReaderWorkspaceModelsTest.kt | 138 +- .../shared/ui/SharedAppThemeColorMathTest.kt | 20 + ...redNativePaginatedReaderInteractionTest.kt | 83 + .../shared/ui/SharedReaderModalSizingTest.kt | 19 + .../reader/SharedReaderDiagnostics.desktop.kt | 23 + .../shared/ui/DesktopBookCoverImageCache.kt | 105 + .../ui/DesktopEpubNativeImage.desktop.kt | 174 + .../shared/ui/LocalBookCoverImage.desktop.kt | 31 +- .../ui/SharedReaderModalLayer.desktop.kt | 133 + .../shared/ReaderTtsFileCacheManagerTest.kt | 1 - .../reader/SharedEpubMetadataEditorTest.kt | 166 + .../reader/SharedEpubPaginationCacheTest.kt | 147 + .../reader/SharedJvmBookLoadCacheTest.kt | 78 + .../shared/reader/SharedJvmBookLoaderTest.kt | 65 +- .../reader/SharedJvmLruMemoryCacheTest.kt | 22 + .../reader/SharedJvmUserDirectoriesTest.kt | 32 + .../reader/SharedMeasuredEpubPaginatorTest.kt | 136 + .../ui/DesktopBookCoverImageCacheTest.kt | 69 + .../reader/paginatedreader/HtmlParser.kt | 353 +- .../shared/reader/SharedEpubMetadataEditor.kt | 278 + .../reader/SharedEpubPaginationCache.kt | 294 + .../shared/reader/SharedJvmBookLoadCache.kt | 214 + .../shared/reader/SharedJvmBookLoader.kt | 242 +- .../shared/reader/SharedJvmLruMemoryCache.kt | 29 + .../shared/reader/SharedJvmUserDirectories.kt | 31 + .../reader/SharedMeasuredEpubPaginator.kt | 1205 +++ 247 files changed, 43321 insertions(+), 7087 deletions(-) create mode 100644 .idea/androidTestResultsUserPreferences.xml create mode 100644 app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt create mode 100644 app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt create mode 100644 app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt create mode 100644 app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt delete mode 100644 app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt create mode 100644 app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt create mode 100644 app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt create mode 100644 app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt create mode 100644 app/src/main/java/com/aryan/reader/SettingsScreen.kt create mode 100644 app/src/main/java/com/aryan/reader/ThemedBookCover.kt create mode 100644 app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt create mode 100644 app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt create mode 100644 app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt create mode 100644 app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt create mode 100644 app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt delete mode 100644 app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt create mode 100644 app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt create mode 100644 app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt create mode 100644 desktopApp/compose-desktop.pro create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt create mode 100644 desktopApp/src/desktopMain/resources/episteme.ico create mode 100644 desktopApp/src/desktopMain/resources/episteme_icon.png create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBookImporterTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistory.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderContentRenderPlan.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderMinimalSlider.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderScrollbars.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSettingsHub.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedStableTextFields.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderDefaultSettingsStateTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SettingsHubModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedImportPlannerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedReducersTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistoryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderSpreadLayoutTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedReaderModalSizingTest.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCache.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopEpubNativeImage.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.desktop.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubMetadataEditorTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoadCacheTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectoriesTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCacheTest.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubMetadataEditor.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCache.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoadCache.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCache.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectories.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt diff --git a/.gitignore b/.gitignore index 633a1fb..1984a25 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,6 @@ google-services.json third_party/pdfium/ *.tgz kcef-bundle/ +kcef-bundle-linux-x64/ cache/ worker/ \ No newline at end of file diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..b8dcf92 --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,594 @@ + + + + + + \ No newline at end of file diff --git a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt index 7fa5d16..d7fd42a 100644 --- a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt @@ -78,6 +78,19 @@ class AppNavigationTest { assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute) } + @Test + fun appNavigation_whenPptxSelected_navigatesToPdfViewer() { + fakeUiState.value = ReaderScreenState( + selectedFileType = FileType.PPTX, + selectedPdfUri = Uri.parse("content://dummy.pptx") + ) + + composeTestRule.waitForIdle() + + val currentRoute = navController.currentBackStackEntry?.destination?.route + assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute) + } + @Test fun appNavigation_whenEpubSelected_navigatesToEpubReader() { // Trigger state change @@ -115,4 +128,19 @@ class AppNavigationTest { val currentRoute = navController.currentBackStackEntry?.destination?.route assertEquals(AppDestinations.MAIN_ROUTE, currentRoute) } -} \ No newline at end of file + + @Test + fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() { + fakeUiState.value = ReaderScreenState( + selectedFileType = FileType.PDF, + selectedPdfUri = Uri.parse("content://dummy.pdf") + ) + composeTestRule.waitForIdle() + assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route) + + fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN) + composeTestRule.waitForIdle() + + assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route) + } +} diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt index 25996eb..dcfe42e 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt @@ -352,6 +352,39 @@ class HtmlParserTest { assertThat(pBlock.text).isEqualTo("Line one.\nLine two.") } + @Test + fun htmlToSemanticBlocks_veryLongInlineParagraph_splitsIntoBoundedParagraphs() { + val longText = "a".repeat(40_000) + val blocks = parse("

$longText

") + val paragraphs = blocks.filterIsInstance() + + assertThat(paragraphs.size).isAtLeast(2) + assertThat(paragraphs.sumOf { it.text.length }).isEqualTo(longText.length) + assertThat(paragraphs.all { it.text.length <= 32_000 }).isTrue() + assertThat( + paragraphs.zipWithNext().all { (previous, next) -> + next.startCharOffsetInSource > previous.startCharOffsetInSource + } + ).isTrue() + } + + @Test + fun htmlToSemanticBlocks_deepInlineWrapperWithBlockDescendant_parsesWithoutSelectorRecursion() { + val mathId = "deep-math" + val mathPlaceholder = """""" + val nestedHtml = (1..600).fold(mathPlaceholder) { content, _ -> + "$content" + } + + val blocks = parse( + html = nestedHtml, + mathSvgCache = mapOf(mathId to "x") + ) + + assertThat(blocks).hasSize(1) + assertThat(blocks.first()).isInstanceOf(SemanticMath::class.java) + } + @Test fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() { // SETUP @@ -372,4 +405,4 @@ class HtmlParserTest { val imageBlock = block as SemanticImage assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0578e8c..cb03ebf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -164,6 +164,16 @@ + + + + + + + + + + @@ -240,4 +250,4 @@ - \ No newline at end of file + diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index b5d78e9..8c7d2e1 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -82,6 +82,34 @@ max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain; } + #content-container a[href], + #content-container a[href]:link, + #content-container a[href]:visited, + body a[href], + body a[href]:link, + body a[href]:visited, + a[href], + a[href]:link, + a[href]:visited { + color: var(--reader-link, #005FCC) !important; + cursor: pointer; + text-decoration-line: underline !important; + text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important; + text-decoration-thickness: 0.08em; + text-decoration-thickness: max(1px, 0.08em); + text-underline-offset: 0.14em; + text-decoration-skip-ink: auto; + background-image: linear-gradient(transparent 62%, var(--reader-link-bg, rgba(0, 95, 204, 0.16)) 62%); + border-radius: 2px; + } + + #content-container a[href] *, + body a[href] *, + a[href] * { + color: var(--reader-link, #005FCC) !important; + text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important; + } + figure img { height: auto !important; } @@ -206,6 +234,7 @@ var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF'); var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000'); + var linkPalette = getReaderLinkPalette(isDark, effectiveBg, effectiveText); var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha)); var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg); @@ -221,6 +250,9 @@ :root { --reader-bg: ${effectiveBg}; --reader-text: ${effectiveText}; + --reader-link: ${linkPalette.color}; + --reader-link-decoration: ${linkPalette.color}; + --reader-link-bg: ${linkPalette.background}; } html.${themeClassName}, html.${themeClassName} body { background-color: var(--reader-bg) !important; @@ -228,21 +260,46 @@ ${textureCss} } - html.${themeClassName} a { - color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important; + html.${themeClassName} body a[href], + html.${themeClassName} body a[href]:link, + html.${themeClassName} body a[href]:visited, + html.${themeClassName} a[href], + html.${themeClassName} a[href]:link, + html.${themeClassName} a[href]:visited { + color: var(--reader-link) !important; + cursor: pointer; + text-decoration-line: underline !important; + text-decoration-color: var(--reader-link-decoration) !important; + text-decoration-thickness: 0.08em; + text-decoration-thickness: max(1px, 0.08em); + text-underline-offset: 0.14em; + text-decoration-skip-ink: auto; + background-image: linear-gradient(transparent 62%, var(--reader-link-bg) 62%); + border-radius: 2px; } - html.${themeClassName} a p, - html.${themeClassName} a div, - html.${themeClassName} a span, - html.${themeClassName} a li, - html.${themeClassName} a h1, - html.${themeClassName} a h2, - html.${themeClassName} a h3, - html.${themeClassName} a h4, - html.${themeClassName} a h5, - html.${themeClassName} a h6 { - color: var(--reader-text) !important; + html.${themeClassName} body a[href] p, + html.${themeClassName} body a[href] div, + html.${themeClassName} body a[href] span, + html.${themeClassName} body a[href] li, + html.${themeClassName} body a[href] h1, + html.${themeClassName} body a[href] h2, + html.${themeClassName} body a[href] h3, + html.${themeClassName} body a[href] h4, + html.${themeClassName} body a[href] h5, + html.${themeClassName} body a[href] h6, + html.${themeClassName} a[href] p, + html.${themeClassName} a[href] div, + html.${themeClassName} a[href] span, + html.${themeClassName} a[href] li, + html.${themeClassName} a[href] h1, + html.${themeClassName} a[href] h2, + html.${themeClassName} a[href] h3, + html.${themeClassName} a[href] h4, + html.${themeClassName} a[href] h5, + html.${themeClassName} a[href] h6 { + color: var(--reader-link) !important; + text-decoration-color: var(--reader-link-decoration) !important; background-color: transparent !important; } @@ -290,6 +347,67 @@ } : {r:255,g:255,b:255}; } + function rgbToHex(rgb) { + function channel(value) { + var hex = Math.max(0, Math.min(255, value)).toString(16); + return hex.length < 2 ? '0' + hex : hex; + } + return '#' + channel(rgb.r) + channel(rgb.g) + channel(rgb.b); + } + + function contrastRatio(first, second) { + var firstLum = getLuminance(first.r, first.g, first.b); + var secondLum = getLuminance(second.r, second.g, second.b); + var lighter = Math.max(firstLum, secondLum); + var darker = Math.min(firstLum, secondLum); + return (lighter + 0.05) / (darker + 0.05); + } + + function getReaderLinkPalette(isDark, bgHex, textHex) { + var bg = hexToRgb(bgHex); + var text = hexToRgb(textHex); + var bgLum = getLuminance(bg.r, bg.g, bg.b); + var textLum = getLuminance(text.r, text.g, text.b); + var candidates = (isDark || bgLum < 0.45) + ? [ + { r: 125, g: 211, b: 252 }, + { r: 94, g: 234, b: 212 }, + { r: 165, g: 180, b: 252 }, + { r: 253, g: 230, b: 138 }, + { r: 255, g: 255, b: 255 } + ] + : [ + { r: 0, g: 95, b: 204 }, + { r: 0, g: 109, b: 117 }, + { r: 122, g: 30, b: 82 }, + { r: 74, g: 20, b: 140 }, + { r: 17, g: 24, b: 39 } + ]; + + var best = candidates[0]; + var bestScore = -1; + for (var i = 0; i < candidates.length; i++) { + var candidate = candidates[i]; + var contrast = contrastRatio(candidate, bg); + var separation = Math.abs(getLuminance(candidate.r, candidate.g, candidate.b) - textLum); + if (contrast >= 4.5 && separation >= 0.08) { + best = candidate; + break; + } + var score = contrast * 10 + separation; + if (score > bestScore) { + bestScore = score; + best = candidate; + } + } + + var alpha = bgLum < 0.45 ? 0.24 : 0.16; + return { + color: rgbToHex(best), + background: `rgba(${best.r}, ${best.g}, ${best.b}, ${alpha})` + }; + } + function rgbStringToRgb(rgbStr) { var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (parts) { @@ -346,6 +464,7 @@ var elements = document.querySelectorAll('[style*="color"]'); elements.forEach(function(el) { + if (el.closest && el.closest('a[href]')) return; var style = window.getComputedStyle(el); var colorStr = style.color; var rgb = rgbStringToRgb(colorStr); diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index e56b5fe..94a881d 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -333,6 +333,58 @@ static bool init_pdfium() { return get_annot_count_func != nullptr; } +static constexpr int kMaxSafeAnnotCount = 100000; + +static int get_safe_annot_count(void* page) { + if (!get_annot_count_func || page == nullptr) return 0; + int count = get_annot_count_func(page); + if (count < 0 || count > kMaxSafeAnnotCount) { + LOGE("Ignoring invalid annotation count: %d", count); + return 0; + } + return count; +} + +class ScopedPdfAnnot { +public: + explicit ScopedPdfAnnot(void* annot) : annot_(annot) {} + ~ScopedPdfAnnot() { + if (annot_ && close_annot_func) { + close_annot_func(annot_); + } + } + + ScopedPdfAnnot(const ScopedPdfAnnot&) = delete; + ScopedPdfAnnot& operator=(const ScopedPdfAnnot&) = delete; + + ScopedPdfAnnot(ScopedPdfAnnot&& other) noexcept : annot_(other.annot_) { + other.annot_ = nullptr; + } + + ScopedPdfAnnot& operator=(ScopedPdfAnnot&& other) noexcept { + if (this != &other) { + if (annot_ && close_annot_func) { + close_annot_func(annot_); + } + annot_ = other.annot_; + other.annot_ = nullptr; + } + return *this; + } + + void* get() const { return annot_; } + +private: + void* annot_; +}; + +static ScopedPdfAnnot get_annot_checked(void* page, jint index) { + if (!get_annot_func || page == nullptr || index < 0) return ScopedPdfAnnot(nullptr); + int count = get_safe_annot_count(page); + if (index >= count) return ScopedPdfAnnot(nullptr); + return ScopedPdfAnnot(get_annot_func(page, index)); +} + extern "C" JNIEXPORT jdouble JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) { std::lock_guard lock(g_pdfium_mutex); @@ -420,17 +472,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas extern "C" JNIEXPORT jstring JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr; + if (!init_pdfium() || !get_annot_string_func || pagePtr == 0 || key == nullptr) return nullptr; void* page = reinterpret_cast(pagePtr); - void* annot = get_annot_func(page, index); - if (!annot) return nullptr; + ScopedPdfAnnot annot = get_annot_checked(page, index); + if (!annot.get()) return nullptr; const char* nativeKey = env->GetStringUTFChars(key, nullptr); + if (!nativeKey) return nullptr; if (strcmp(nativeKey, "IRT") == 0) { if (get_linked_annot_func && close_annot_func) { - void* parentAnnot = get_linked_annot_func(annot, "IRT"); + void* parentAnnot = get_linked_annot_func(annot.get(), "IRT"); if (parentAnnot) { unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); jstring result = nullptr; @@ -448,7 +501,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass return nullptr; } - unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0); + unsigned long len = get_annot_string_func(annot.get(), nativeKey, nullptr, 0); if (len <= 2) { env->ReleaseStringUTFChars(key, nativeKey); @@ -456,7 +509,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass } std::vector buffer(len / 2); - get_annot_string_func(annot, nativeKey, buffer.data(), len); + get_annot_string_func(annot.get(), nativeKey, buffer.data(), len); jstring result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); @@ -1592,15 +1645,17 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1; + if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return -1; void* page = reinterpret_cast(pagePtr); - int count = get_annot_count_func(page); + int count = get_safe_annot_count(page); for (int i = 0; i < count; i++) { - void* annot = get_annot_func(page, i); + ScopedPdfAnnot annot = get_annot_checked(page, i); + if (!annot.get()) continue; + float r[4]; // L, B, R, T - if (get_annot_rect_func(annot, r)) { + if (get_annot_rect_func(annot.get(), r)) { // FIX: Use min/max to handle inverted PDF rectangles float minX = fmin(r[0], r[2]); float maxX = fmax(r[0], r[2]); @@ -1608,8 +1663,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, float maxY = fmax(r[1], r[3]); if (x >= minX && x <= maxX && y >= minY && y <= maxY) { - LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot)); - return get_annot_subtype_func(annot); + int subtype = get_annot_subtype_func(annot.get()); + LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, subtype); + return subtype; } } } @@ -1619,15 +1675,22 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; + if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; void* page = reinterpret_cast(pagePtr); - int count = get_annot_count_func(page); + int count = get_safe_annot_count(page); for (int i = 0; i < count; i++) { - void* annot = get_annot_func(page, i); + ScopedPdfAnnot annot = get_annot_checked(page, i); + if (!annot.get()) continue; + float rect[4]; - if (get_annot_rect_func(annot, rect)) { - if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) { + if (get_annot_rect_func(annot.get(), rect)) { + float minX = fminf(rect[0], rect[2]); + float maxX = fmaxf(rect[0], rect[2]); + float minY = fminf(rect[1], rect[3]); + float maxY = fmaxf(rect[1], rect[3]); + + if (x >= minX && x <= maxX && y >= minY && y <= maxY) { jfloatArray result = env->NewFloatArray(4); env->SetFloatArrayRegion(result, 0, 4, rect); return result; @@ -1641,26 +1704,26 @@ extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0; - return get_annot_count_func(reinterpret_cast(pagePtr)); + return get_safe_annot_count(reinterpret_cast(pagePtr)); } extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0; - void* annot = get_annot_func(reinterpret_cast(pagePtr), index); - return annot ? get_annot_subtype_func(annot) : 0; + if (!init_pdfium() || !get_annot_subtype_func || pagePtr == 0) return 0; + ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast(pagePtr), index); + return annot.get() ? get_annot_subtype_func(annot.get()) : 0; } extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; - void* annot = get_annot_func(reinterpret_cast(pagePtr), index); - if (!annot) return nullptr; + if (!init_pdfium() || !get_annot_rect_func || pagePtr == 0) return nullptr; + ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast(pagePtr), index); + if (!annot.get()) return nullptr; float rect[4]; - if (!get_annot_rect_func(annot, rect)) return nullptr; + if (!get_annot_rect_func(annot.get(), rect)) return nullptr; jfloatArray result = env->NewFloatArray(4); env->SetFloatArrayRegion(result, 0, 4, rect); @@ -1670,31 +1733,30 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { std::lock_guard lock(g_pdfium_mutex); - if (!init_pdfium() || pagePtr == 0) return JNI_FALSE; + if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return JNI_FALSE; void* page = reinterpret_cast(pagePtr); - int count = get_annot_count_func(page); - void* hitAnnot = nullptr; + int count = get_safe_annot_count(page); + int hitSubtype = 0; LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count); for (int i = 0; i < count; i++) { - void* annot = get_annot_func(page, i); - if (!annot) continue; + ScopedPdfAnnot annot = get_annot_checked(page, i); + if (!annot.get()) continue; float r[4]; - if (get_annot_rect_func(annot, r)) { + if (get_annot_rect_func(annot.get(), r)) { float minX = fminf(r[0], r[2]); float maxX = fmaxf(r[0], r[2]); float minY = fminf(r[1], r[3]); float maxY = fmaxf(r[1], r[3]); if (x >= minX && x <= maxX && y >= minY && y <= maxY) { - hitAnnot = annot; - int subtype = get_annot_subtype_func(hitAnnot); - LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype); + hitSubtype = get_annot_subtype_func(annot.get()); + LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, hitSubtype); if (get_annot_flags_func) { - int flags = get_annot_flags_func(hitAnnot); + int flags = get_annot_flags_func(annot.get()); LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags); } break; @@ -1702,25 +1764,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl } } - if (hitAnnot) { - int subtype = get_annot_subtype_func(hitAnnot); - - if (subtype == 19 || subtype == 20) { - LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype); + if (hitSubtype != 0) { + if (hitSubtype == 19 || hitSubtype == 20) { + LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", hitSubtype); bool anyChanged = false; for (int j = 0; j < count; j++) { - void* target = get_annot_func(page, j); - if (!target || !get_annot_flags_func || !set_annot_flags_func) continue; + ScopedPdfAnnot target = get_annot_checked(page, j); + if (!target.get() || !get_annot_flags_func || !set_annot_flags_func) continue; - int flags = get_annot_flags_func(target); + int flags = get_annot_flags_func(target.get()); // We check for: Invisible (1), Hidden (2), or NoView (32) if (flags & (1 | 2 | 32)) { LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags); // Clear bits 1, 2, and 6 (1 + 2 + 32 = 35) - set_annot_flags_func(target, flags & ~35); + set_annot_flags_func(target.get(), flags & ~35); anyChanged = true; } } diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt new file mode 100644 index 0000000..acd0072 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt @@ -0,0 +1,49 @@ +package com.aryan.reader + +import com.aryan.reader.shared.SharedFeaturePolicy +import com.aryan.reader.shared.SharedSettingsHubInput +import com.aryan.reader.shared.SharedSettingsPlatform + +fun androidSettingsHubInput( + uiState: ReaderScreenState, + isOssBuild: Boolean = BuildConfig.FLAVOR == "oss", + isOfflineBuild: Boolean = BuildConfig.IS_OFFLINE, + isDebugBuild: Boolean = BuildConfig.DEBUG, + hideReaderAi: Boolean = false +): SharedSettingsHubInput { + val supportsSync = !isOssBuild && !isOfflineBuild + val supportsOssAiKeys = isOssBuild && !isOfflineBuild + val featurePolicy = if (isOfflineBuild) { + SharedFeaturePolicy.OssOffline + } else { + SharedFeaturePolicy.Standard + } + return SharedSettingsHubInput( + platform = SharedSettingsPlatform.ANDROID, + featurePolicy = featurePolicy, + isDebugBuild = isDebugBuild, + isSignedIn = uiState.currentUser != null, + isProUser = uiState.isProUser, + syncAvailable = supportsSync, + folderSyncAvailable = supportsSync, + aiSettingsAvailable = supportsOssAiKeys, + ttsSettingsAvailable = true, + includePdfReaderDefaults = true, + includeReaderToolbar = true, + includeLanguage = true, + includeScreenCaptureProtection = true, + includeExternalFileBehavior = true, + includeRecentLimit = true, + includeCustomFonts = true, + includeStrictFileFilter = true, + includeHideReaderAi = !isOfflineBuild, + includeCloudLocalDataClear = supportsSync, + supportProjectAvailable = isOssBuild, + isTabsEnabled = uiState.isTabsEnabled, + isSyncEnabled = uiState.isSyncEnabled, + isFolderSyncEnabled = uiState.isFolderSyncEnabled, + useStrictFileFilter = uiState.useStrictFileFilter, + isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled, + hideReaderAi = hideReaderAi + ) +} diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt new file mode 100644 index 0000000..1539a6f --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt @@ -0,0 +1,235 @@ +package com.aryan.reader + +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity +import com.aryan.reader.shared.AppAction as SharedAppAction +import com.aryan.reader.shared.LibraryAction as SharedLibraryAction +import com.aryan.reader.shared.SharedFolderPathResolver +import com.aryan.reader.shared.SharedLibraryProjectionInput +import com.aryan.reader.shared.SharedLibraryStateProjector +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.reduce + +internal object AndroidSharedStateBridge { + fun prepareLibraryProjection( + input: LibraryProjectionInput, + folderPathResolver: FolderPathResolver + ): AndroidSharedLibraryProjectionContext { + val taggedBooks = input.recentFilesFromDb.withResolvedTags(input.dbTags, input.tagRefs) + val androidBooksById = taggedBooks + .filterNot { it.bookId.endsWith("_reflow") } + .associateBy { it.bookId } + val projectionState = input.state.withAndroidFolderFallbacks(androidBooksById.values) + val sharedInput = SharedLibraryProjectionInput( + state = projectionState.toSharedReaderScreenState( + rawBooks = taggedBooks, + dbTags = input.dbTags + ), + booksFromStore = taggedBooks + .filterNot { it.bookId.endsWith("_reflow") } + .map { it.toSharedProjectionBookItem() }, + shelfRecords = input.dbShelves.map { it.toSharedShelfRecord() }, + shelfRefs = input.shelfRefs.map { it.toSharedBookShelfRef() }, + tags = input.dbTags.map { it.toSharedTag() } + ) + return AndroidSharedLibraryProjectionContext( + projectionState = projectionState, + sharedInput = sharedInput, + androidBooksById = androidBooksById, + tagEntitiesById = input.dbTags.associateBy { it.id }, + folderKeys = projectionState.syncedFolders.map { AndroidSharedFolderProjectionKey(it.uriString, it.name) }, + folderPathResolver = SharedFolderPathResolver { item -> + androidBooksById[item.id]?.let(folderPathResolver::relativeFolderSegments).orEmpty() + } + ) + } + + fun projectLibrary(context: AndroidSharedLibraryProjectionContext): SharedReaderScreenState { + return SharedLibraryStateProjector(context.folderPathResolver).project(context.sharedInput) + } + + fun toAndroidState( + base: ReaderScreenState, + sharedState: SharedReaderScreenState, + androidBooksById: Map, + tagEntitiesById: Map + ): ReaderScreenState { + return sharedState.toAndroidReaderScreenState( + base = base, + androidBooksById = androidBooksById, + tagEntitiesById = tagEntitiesById + ) + } + + fun reduceLibraryAction( + current: ReaderScreenState, + projectedState: ReaderScreenState, + action: SharedLibraryAction + ): ReaderScreenState { + val rawBooks = projectedState.rawLibraryFiles.ifEmpty { current.rawLibraryFiles } + val androidBooksById = (rawBooks + current.contextualActionItems).associateBy { it.bookId } + val reduced = current.toBridgeSharedState(projectedState).reduce(action) + return current.copy( + searchQuery = reduced.searchQuery, + sortOrder = reduced.sortOrder.toAndroidSortOrder(), + libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(), + contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] }, + contextualActionShelfIds = reduced.selectedShelfIds, + libraryScreenStartPage = reduced.libraryScreenStartPage, + recentFilesLimit = reduced.recentFilesLimit + ) + } + + fun reduceAppAction( + current: ReaderScreenState, + projectedState: ReaderScreenState, + action: SharedAppAction + ): ReaderScreenState { + val reduced = current.toBridgeSharedState(projectedState).reduce(action) + return current.copy( + appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(), + appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(), + appTextDimFactorLight = reduced.appTextDimFactorLight, + appTextDimFactorDark = reduced.appTextDimFactorDark, + appSeedColor = reduced.appSeedColor, + customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() } + ) + } + + fun setTabsEnabled( + current: ReaderScreenState, + projectedState: ReaderScreenState, + enabled: Boolean + ): ReaderScreenState { + val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.TabsEnabledChanged(enabled)) + if (enabled) return current.withTabStateFrom(reduced) + + val activeTab = current.activeTabBookId + return current.copy( + isTabsEnabled = reduced.isTabsEnabled, + openTabIds = if (activeTab == null) emptyList() else listOf(activeTab), + activeTabBookId = activeTab + ) + } + + fun openBookTab( + current: ReaderScreenState, + projectedState: ReaderScreenState, + bookId: String + ): ReaderScreenState { + val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabOpened(bookId)) + return current.withTabStateFrom(reduced) + } + + fun closeBookTab( + current: ReaderScreenState, + projectedState: ReaderScreenState, + bookId: String + ): ReaderScreenState { + val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabClosed(bookId)) + return current.withTabStateFrom(reduced) + } + + fun closeAllTabs(current: ReaderScreenState, projectedState: ReaderScreenState): ReaderScreenState { + val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.AllTabsClosed) + return current.withTabStateFrom(reduced) + } + + fun togglePinsForSelectedBooks( + current: ReaderScreenState, + projectedState: ReaderScreenState, + isHome: Boolean + ): ReaderScreenState { + val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId } + if (selectedIds.isEmpty()) return current + + val currentPins = if (isHome) current.pinnedHomeBookIds else current.pinnedLibraryBookIds + val idsToToggle = if (selectedIds.all { it in currentPins }) { + selectedIds + } else { + selectedIds - currentPins + } + val reduced = idsToToggle.fold(current.toBridgeSharedState(projectedState)) { state, bookId -> + state.reduce( + if (isHome) { + SharedAppAction.HomePinToggled(bookId) + } else { + SharedAppAction.LibraryPinToggled(bookId) + } + ) + } + + return if (isHome) { + current.copy( + pinnedHomeBookIds = reduced.pinnedHomeBookIds, + contextualActionItems = emptySet() + ) + } else { + current.copy( + pinnedLibraryBookIds = reduced.pinnedLibraryBookIds, + contextualActionItems = emptySet() + ) + } + } + + fun replaceBookSelectionWithVisibleBooks( + current: ReaderScreenState, + projectedState: ReaderScreenState, + visibleBooks: Collection + ): ReaderScreenState { + val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.bookId } + val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId } + val action = if (visibleIds.isNotEmpty() && selectedIds.containsAll(visibleIds)) { + SharedLibraryAction.SelectionCleared + } else { + SharedLibraryAction.BookSelectionReplaced(visibleIds) + } + return reduceLibraryAction( + current = current, + projectedState = projectedState, + action = action + ) + } + + private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState { + return toSharedReaderScreenState( + rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles }, + dbTags = projectedState.allTags.ifEmpty { allTags } + ) + } + + private fun ReaderScreenState.withTabStateFrom(sharedState: SharedReaderScreenState): ReaderScreenState { + return copy( + isTabsEnabled = sharedState.isTabsEnabled, + openTabIds = sharedState.openTabIds, + activeTabBookId = sharedState.activeTabBookId + ) + } +} + +internal data class AndroidSharedLibraryProjectionContext( + val projectionState: ReaderScreenState, + val sharedInput: SharedLibraryProjectionInput, + val androidBooksById: Map, + val tagEntitiesById: Map, + val folderKeys: List, + val folderPathResolver: SharedFolderPathResolver +) + +internal data class AndroidSharedFolderProjectionKey( + val uriString: String, + val name: String +) + +private fun ReaderScreenState.withAndroidFolderFallbacks(books: Collection): ReaderScreenState { + val knownFolders = syncedFolders.mapTo(mutableSetOf()) { it.uriString } + val missingFolders = books + .mapNotNull { it.sourceFolderUri } + .filterTo(linkedSetOf()) { it !in knownFolders } + .map { uri -> SyncedFolder(uriString = uri, name = "Local Folder", lastScanTime = 0L) } + return if (missingFolders.isEmpty()) { + this + } else { + copy(syncedFolders = syncedFolders + missingFolders) + } +} diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index 1d43227..626dbb8 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -65,6 +65,7 @@ object AppDestinations { const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" + const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" } private fun NavHostController.isReadyForBackStackChange(): Boolean { @@ -146,7 +147,7 @@ fun AppNavigation( LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { when (uiState.selectedFileType) { - FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { + FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> { if (uiState.selectedPdfUri != null) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) @@ -165,6 +166,11 @@ fun AppNavigation( navController.syncRouteTo(AppDestinations.MAIN_ROUTE) } } + FileType.UNKNOWN -> { + if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) { + navController.syncRouteTo(AppDestinations.MAIN_ROUTE) + } + } } } } @@ -225,6 +231,8 @@ fun AppNavigation( CircularProgressIndicator() } } + + CustomTopBanner(bannerMessage = uiState.bannerMessage) } } else if (uiState.isLoading) { Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.") @@ -301,6 +309,8 @@ fun AppNavigation( CircularProgressIndicator() } } + + CustomTopBanner(bannerMessage = uiState.bannerMessage) } } isLoading -> { @@ -361,5 +371,13 @@ fun AppNavigation( onBackClick = { navController.popBackStackIfReady() } ) } + + composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) { + SettingsScreen( + viewModel = viewModel, + navController = navController, + onBackClick = { navController.popBackStackIfReady() } + ) + } } } diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt index a5155dc..2907a9c 100644 --- a/app/src/main/java/com/aryan/reader/AppUiModels.kt +++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt @@ -8,7 +8,11 @@ import com.aryan.reader.epub.EpubBook import com.aryan.reader.paginatedreader.Locator import java.util.Date -data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false) +typealias BannerMessage = com.aryan.reader.shared.BannerMessage +typealias UserData = com.aryan.reader.shared.UserData +typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode +typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption +typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme data class ImportResult( val internalUri: Uri, @@ -17,37 +21,12 @@ data class ImportResult( val bundleResult: CalibreBundleResult? = null ) -data class UserData( - val uid: String, - val displayName: String?, - val photoUrl: String?, - val email: String? -) - data class NavigationEvent( val route: String, val bookId: String? = null, val uri: Uri? = null ) -enum class AppThemeMode { - SYSTEM, - LIGHT, - DARK -} - -enum class AppContrastOption(val value: Double) { - STANDARD(0.0), - MEDIUM(0.5), - HIGH(1.0) -} - -data class CustomAppTheme( - val id: String, - val name: String, - val seedColor: androidx.compose.ui.graphics.Color -) - data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?) data class DeviceLimitReachedState( @@ -109,7 +88,7 @@ data class ReaderScreenState( val pinnedLibraryBookIds: Set = emptySet(), val libraryFilters: LibraryFilters = LibraryFilters(), val recentFilesLimit: Int = 0, - val isTabsEnabled: Boolean = false, + val isTabsEnabled: Boolean = true, val openTabIds: List = emptyList(), val openTabs: List = emptyList(), val activeTabBookId: String? = null, diff --git a/app/src/main/java/com/aryan/reader/BookImporter.kt b/app/src/main/java/com/aryan/reader/BookImporter.kt index 1b6d9c0..55714c7 100644 --- a/app/src/main/java/com/aryan/reader/BookImporter.kt +++ b/app/src/main/java/com/aryan/reader/BookImporter.kt @@ -109,8 +109,9 @@ class BookImporter(private val context: Context) { when (context.contentResolver.getType(uri)) { "application/pdf" -> "pdf" "application/epub+zip" -> "epub" + "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx" else -> "tmp" } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 98a7f64..df292c2 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -4,6 +4,7 @@ package com.aryan.reader import android.content.Context +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.security.keystore.KeyGenParameterSpec @@ -209,6 +210,7 @@ import javax.crypto.spec.GCMParameterSpec import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +import kotlin.math.sqrt const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val summarizeEndpoint = "/summarize" @@ -2487,11 +2489,75 @@ fun importReaderTexture(context: Context, uri: Uri): String? { } } +private const val DEFAULT_CANVAS_SAFE_BITMAP_BYTES = 64L * 1024L * 1024L +private const val DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION = 4096 +private const val MAX_READER_TEXTURE_DIMENSION_PX = 1024 + +fun Bitmap.safeAllocationByteCount(): Long { + return try { + allocationByteCount.toLong() + } catch (_: Exception) { + width.toLong() * height.toLong() * 4L + } +} + +fun Bitmap.isCanvasSafeBitmap( + maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES, + maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION +): Boolean { + return !isRecycled && + width > 0 && + height > 0 && + width <= maxDimension && + height <= maxDimension && + safeAllocationByteCount() <= maxBytes +} + +fun Bitmap.scaledToCanvasLimit( + maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES, + maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION +): Bitmap { + if (isCanvasSafeBitmap(maxBytes, maxDimension)) return this + + val byteScale = sqrt(maxBytes.toDouble() / safeAllocationByteCount().coerceAtLeast(1L).toDouble()) + val dimensionScale = maxDimension.toDouble() / max(width, height).coerceAtLeast(1).toDouble() + val scale = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01) + val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true) +} + +private fun decodeSampledBitmapFile(path: String, maxDimension: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = calculateBitmapSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + } + return BitmapFactory.decodeFile(path, options) +} + +private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int): Int { + var sampleSize = 1 + var sampledWidth = width + var sampledHeight = height + while (sampledWidth / 2 >= maxDimension || sampledHeight / 2 >= maxDimension) { + sampleSize *= 2 + sampledWidth /= 2 + sampledHeight /= 2 + } + return sampleSize.coerceAtLeast(1) +} + fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? { if (textureId == null) return null return try { val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { - BitmapFactory.decodeFile(textureId.removePrefix(TEXTURE_FILE_PREFIX)) + decodeSampledBitmapFile( + path = textureId.removePrefix(TEXTURE_FILE_PREFIX), + maxDimension = MAX_READER_TEXTURE_DIMENSION_PX + ) } else { val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null when { @@ -2500,7 +2566,14 @@ fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? else -> null } } - bitmap?.asImageBitmap() + val safeBitmap = bitmap?.scaledToCanvasLimit( + maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES, + maxDimension = MAX_READER_TEXTURE_DIMENSION_PX + ) + if (bitmap != null && safeBitmap !== bitmap && !bitmap.isRecycled) { + bitmap.recycle() + } + safeBitmap?.asImageBitmap() } catch (e: Exception) { Timber.e(e, "Failed to load reader texture bitmap: $textureId") null @@ -2513,8 +2586,24 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? { var mimeType = "image/png" val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX)) - mimeType = imageMimeTypeForExtension(file.extension) - file.readBytes() + mimeType = "image/png" + val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX) + ?: return null + val bitmap = decodedBitmap.scaledToCanvasLimit( + maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES, + maxDimension = MAX_READER_TEXTURE_DIMENSION_PX + ) + try { + ByteArrayOutputStream().use { out -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 90, out) + out.toByteArray() + } + } finally { + if (bitmap !== decodedBitmap && !decodedBitmap.isRecycled) { + decodedBitmap.recycle() + } + bitmap.recycle() + } } else { val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null when { diff --git a/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt b/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt new file mode 100644 index 0000000..36c2574 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt @@ -0,0 +1,728 @@ +package com.aryan.reader + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.InputStream +import java.net.URLDecoder +import java.nio.charset.Charset +import java.util.Base64 +import java.util.zip.ZipInputStream + +internal data class EmbeddedEbookMetadata( + val title: String? = null, + val author: String? = null, + val description: String? = null, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val cover: EmbeddedEbookCover? = null +) + +internal data class EmbeddedEbookCover( + val bytes: ByteArray, + val extension: String +) + +internal object EmbeddedEbookMetadataExtractor { + private const val MAX_XML_ENTRY_BYTES = 512 * 1024 + private const val MAX_COVER_BYTES = 24 * 1024 * 1024 + private const val MAX_MOBI_HEADER_RECORD_BYTES = 4 * 1024 * 1024 + private const val MAX_MOBI_RECORDS = 65_535 + private const val MAX_MOBI_EXTH_RECORDS = 10_000 + private const val MOBI_NOT_SET = -1 + + private val ebookCoverTypes = setOf(FileType.EPUB, FileType.MOBI, FileType.FB2) + private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") + + fun canExtractEmbeddedCover(type: FileType): Boolean = type in ebookCoverTypes + + fun extract( + type: FileType, + displayName: String, + openStream: () -> InputStream?, + extractCover: Boolean = true + ): EmbeddedEbookMetadata { + return when (type) { + FileType.EPUB -> extractEpub(openStream, extractCover) + FileType.MOBI -> extractMobi(openStream, extractCover) + FileType.FB2 -> extractFb2(displayName, openStream, extractCover) + else -> EmbeddedEbookMetadata() + } + } + + private fun extractEpub(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata { + val containerXml = openStream()?.use { input -> + readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name -> + name.equals("META-INF/container.xml", ignoreCase = true) + }?.text + } + val declaredOpfPath = containerXml + ?.let(::parseEpubRootfilePath) + ?.let(::normalizeZipPath) + ?.takeIf { it.isNotBlank() } + + val opfEntry = declaredOpfPath + ?.let { path -> + openStream()?.use { input -> + readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name -> + name.equals(path, ignoreCase = true) + } + } + } + ?: openStream()?.use { input -> + readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name -> + name.endsWith(".opf", ignoreCase = true) + } + } + ?: return EmbeddedEbookMetadata() + val opfPath = opfEntry.path + val opf = opfEntry.text + + val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "") + .let { if (it.isBlank()) "" else "$it/" } + val manifest = parseEpubManifest(opf) + val cover = if (extractCover) { + val coverItem = findExplicitEpubCover(opf, manifest) + coverItem + ?.takeIf { it.rasterExtension != null } + ?.let { item -> + val rawPath = resolveEpubZipPath(basePath, item.href) + val decodedPath = resolveEpubZipPath(basePath, item.href.percentDecodedOrSelf()) + listOf(decodedPath, rawPath).distinct().firstNotNullOfOrNull { path -> + readZipEntryBytes(openStream, path, MAX_COVER_BYTES)?.let { bytes -> + EmbeddedEbookCover(bytes = bytes, extension = item.rasterExtension ?: "png") + } + } + } + } else { + null + } + + return EmbeddedEbookMetadata( + title = opf.tagText("title"), + author = opf.tagText("creator"), + description = opf.tagInnerContent("description"), + seriesName = opf.metaContent("calibre:series"), + seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(), + cover = cover + ) + } + + private fun readFirstZipTextEntry( + input: InputStream, + maxBytes: Int, + matches: (String) -> Boolean + ): ZipTextEntry? { + var result: ZipTextEntry? = null + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + try { + if (entry.isDirectory) continue + val name = normalizeZipPath(entry.name) + if (matches(name)) { + result = zip.readBytesLimited(maxBytes) + ?.toString(Charsets.UTF_8) + ?.let { ZipTextEntry(path = name, text = it) } + break + } + } finally { + zip.closeEntry() + } + } + } + return result + } + + private fun readZipEntryBytes( + openStream: () -> InputStream?, + targetPath: String, + maxBytes: Int + ): ByteArray? { + return openStream()?.use { input -> + var result: ByteArray? = null + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + try { + if (!entry.isDirectory && normalizeZipPath(entry.name).equals(targetPath, ignoreCase = true)) { + result = zip.readBytesLimited(maxBytes) + break + } + } finally { + zip.closeEntry() + } + } + } + result + } + } + + private fun parseEpubRootfilePath(containerXml: String): String? { + return Regex("""]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE) + .find(containerXml) + ?.groupValues + ?.get(1) + ?.decodeEntities() + ?.takeIf { it.isNotBlank() } + } + + private fun parseEpubManifest(opf: String): List { + return Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(opf) + .mapNotNull { match -> + val item = match.value + val id = item.attr("id") + val href = item.attr("href") + if (id.isBlank() || href.isBlank()) { + null + } else { + EpubManifestItem( + id = id, + href = href.decodeEntities(), + mediaType = item.attr("media-type"), + properties = item.attr("properties") + ) + } + } + .toList() + } + + private fun findExplicitEpubCover(opf: String, manifest: List): EpubManifestItem? { + val coverId = Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(opf) + .firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) } + ?.value + ?.attr("content") + ?.takeIf { it.isNotBlank() } + + return manifest.firstOrNull { it.id == coverId } + ?: manifest.firstOrNull { item -> + item.properties.split(Regex("\\s+")).any { it.equals("cover-image", ignoreCase = true) } + } + } + + private fun extractFb2( + displayName: String, + openStream: () -> InputStream?, + extractCover: Boolean + ): EmbeddedEbookMetadata { + return openStream()?.use { input -> + if (displayName.endsWith(".zip", ignoreCase = true)) { + ZipInputStream(input.buffered()).use { zip -> + var metadata: EmbeddedEbookMetadata? = null + while (true) { + val entry = zip.nextEntry ?: break + try { + if (!entry.isDirectory && entry.name.endsWith(".fb2", ignoreCase = true)) { + metadata = parseFb2Xml(zip, extractCover) + break + } + } finally { + zip.closeEntry() + } + } + metadata ?: EmbeddedEbookMetadata() + } + } else { + parseFb2Xml(input, extractCover) + } + } ?: EmbeddedEbookMetadata() + } + + private fun parseFb2Xml(input: InputStream, extractCover: Boolean): EmbeddedEbookMetadata { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + var title: String? = null + val authors = mutableListOf() + var inAuthor = false + var inBody = false + var inCoverPage = false + val authorParts = mutableListOf() + var coverImageId: String? = null + var cover: EmbeddedEbookCover? = null + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + when (parser.name.localXmlName()) { + "body" -> inBody = true + "coverpage" -> { + if (!inBody) inCoverPage = true + } + "author" -> { + if (!inBody) { + inAuthor = true + authorParts.clear() + } + } + "book-title" -> { + if (title == null) { + title = parser.nextTextOrNull() + } + } + "first-name", "middle-name", "last-name", "nickname" -> { + if (inAuthor) { + parser.nextTextOrNull()?.let(authorParts::add) + } + } + "image" -> { + if (inCoverPage && coverImageId == null) { + coverImageId = parser.hrefAttr()?.removePrefix("#")?.takeIf { it.isNotBlank() } + } + } + "binary" -> { + val id = parser.attrValue("id") + val contentType = parser.attrValue("content-type") + val isExplicitCover = id != null && id == coverImageId + if (extractCover && cover == null && isExplicitCover) { + val encoded = parser.nextTextOrNull() + val decoded = encoded + ?.takeIf { it.length <= MAX_COVER_BYTES * 2 } + ?.decodeBase64OrNull() + val extension = extensionFromMimeType(contentType) + ?: id?.rasterExtension() + ?: decoded?.rasterExtensionFromMagic() + ?: "jpg" + if (decoded != null && decoded.size <= MAX_COVER_BYTES && extension in rasterCoverExtensions) { + cover = EmbeddedEbookCover(bytes = decoded, extension = extension) + } + } + } + } + } + XmlPullParser.END_TAG -> { + when (parser.name.localXmlName()) { + "body" -> inBody = false + "coverpage" -> inCoverPage = false + "author" -> { + if (inAuthor && authorParts.isNotEmpty()) { + authors += authorParts.joinToString(" ").replace(Regex("\\s+"), " ").trim() + } + inAuthor = false + authorParts.clear() + } + } + } + } + event = parser.next() + } + + return EmbeddedEbookMetadata( + title = title, + author = authors.distinct().joinToString(", ").takeIf { it.isNotBlank() }, + cover = cover + ) + } + + private fun extractMobi(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata { + val scan = openStream()?.use { readMobiRecordScan(it) } ?: return EmbeddedEbookMetadata() + val header = scan.headerRecord + val headerInfo = parseMobiHeaderInfo(header) + val charset = when (headerInfo.encoding ?: 1252) { + 65001 -> Charsets.UTF_8 + 1200 -> Charsets.UTF_16 + 1252 -> Charset.forName("windows-1252") + else -> Charsets.UTF_8 + } + val exth = parseMobiExth(header, charset) + val cover = if (extractCover) { + headerInfo.imageIndex + ?.let { imageIndex -> exth.coverOffset?.let { imageIndex + it } } + ?.takeIf { it > 0 } + ?.let { recordIndex -> readMobiRecord(openStream, scan.offsets, recordIndex, MAX_COVER_BYTES) } + ?.takeIf { it.size <= MAX_COVER_BYTES } + ?.let { imageBytes -> + imageBytes.rasterExtensionFromMagic()?.let { extension -> + EmbeddedEbookCover(bytes = imageBytes, extension = extension) + } + } + } else { + null + } + + return EmbeddedEbookMetadata( + title = exth.title, + author = exth.author, + cover = cover + ) + } + + private fun readMobiRecordScan(input: InputStream): MobiRecordScan? { + val buffered = input.buffered() + val palmHeader = buffered.readExactBytesOrNull(78) ?: return null + val recordCount = palmHeader.u16(76) + if (recordCount <= 0 || recordCount > MAX_MOBI_RECORDS) return null + + val recordTable = buffered.readExactBytesOrNull(recordCount * 8) ?: return null + val offsets = (0 until recordCount).map { index -> + recordTable.u32(index * 8).toInt() + } + val record0Start = offsets.getOrNull(0) ?: return null + val record0End = offsets.getOrNull(1) + if (record0Start < 78 + recordTable.size) return null + + val headerRecord = readRecordFromCurrentStream( + input = buffered, + currentOffset = 78 + recordTable.size, + recordStart = record0Start, + recordEnd = record0End, + maxBytes = MAX_MOBI_HEADER_RECORD_BYTES + ) ?: return null + + return MobiRecordScan(offsets = offsets, headerRecord = headerRecord) + } + + private fun readMobiRecord( + openStream: () -> InputStream?, + offsets: List, + recordIndex: Int, + maxBytes: Int + ): ByteArray? { + val recordStart = offsets.getOrNull(recordIndex) ?: return null + val recordEnd = offsets.getOrNull(recordIndex + 1) + if (recordStart < 0) return null + + return openStream()?.use { input -> + val buffered = input.buffered() + readRecordFromCurrentStream( + input = buffered, + currentOffset = 0, + recordStart = recordStart, + recordEnd = recordEnd, + maxBytes = maxBytes + ) + } + } + + private fun readRecordFromCurrentStream( + input: InputStream, + currentOffset: Int, + recordStart: Int, + recordEnd: Int?, + maxBytes: Int + ): ByteArray? { + if (recordStart < currentOffset) return null + if (!input.skipFully((recordStart - currentOffset).toLong())) return null + + val length = recordEnd?.minus(recordStart) + return if (length != null) { + if (length <= 0 || length > maxBytes) return null + input.readExactBytesOrNull(length) + } else { + input.readBytesLimited(maxBytes) + } + } + + private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo { + if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo() + val mobiHeaderLength = header.u32(20).toInt() + + fun u32InHeader(offset: Int): Int? { + if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null + return header.u32(16 + offset).toInt() + .takeIf { it >= 0 && it != MOBI_NOT_SET } + } + + return MobiHeaderInfo( + encoding = u32InHeader(12), + imageIndex = u32InHeader(92) + ) + } + + private fun parseMobiExth(header: ByteArray, charset: Charset): MobiExthMetadata { + if (header.size < 92 || header.asciiAt(16, 4) != "MOBI") return MobiExthMetadata() + val mobiHeaderLength = header.u32(20).toInt() + val fullNameOffset = header.u32(16 + 68).toInt() + val fullNameLength = header.u32(16 + 72).toInt() + val fullName = header.safeString(fullNameOffset, fullNameLength, charset) + var exthTitle: String? = null + var author: String? = null + var coverOffset: Int? = null + val exthOffsetLong = 16L + mobiHeaderLength + if (mobiHeaderLength <= 0 || exthOffsetLong > Int.MAX_VALUE - 12L) { + return MobiExthMetadata(title = fullName.takeUnlessBlank()) + } + val exthOffset = exthOffsetLong.toInt() + + if (exthOffset + 12 <= header.size && header.asciiAt(exthOffset, 4) == "EXTH") { + val recordCount = header.u32(exthOffset + 8).toInt() + var offset = exthOffset + 12 + repeat(recordCount.coerceIn(0, MAX_MOBI_EXTH_RECORDS)) { + if (offset + 8 > header.size) return@repeat + val type = header.u32(offset).toInt() + val size = header.u32(offset + 4).toInt() + if (size < 8 || offset + size > header.size) return@repeat + val dataOffset = offset + 8 + val dataSize = size - 8 + when (type) { + 99 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset) + 100 -> author = author ?: header.safeString(dataOffset, dataSize, charset) + 201 -> coverOffset = coverOffset ?: header.u32(dataOffset).toInt().takeIf { dataSize >= 4 } + 503 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset) + } + offset += size + } + } + + return MobiExthMetadata( + title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(), + author = author.takeUnlessBlank(), + coverOffset = coverOffset + ) + } + + private fun XmlPullParser.nextTextOrNull(): String? { + return try { + nextText()?.trim()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + } + + private fun XmlPullParser.attrValue(localName: String): String? { + for (index in 0 until attributeCount) { + val name = getAttributeName(index).localXmlName() + if (name.equals(localName, ignoreCase = true)) { + return getAttributeValue(index)?.takeIf { it.isNotBlank() } + } + } + return null + } + + private fun XmlPullParser.hrefAttr(): String? { + return attrValue("href") + ?: getAttributeValue("http://www.w3.org/1999/xlink", "href")?.takeIf { it.isNotBlank() } + } + + private fun InputStream.readBytesLimited(maxBytes: Int): ByteArray? { + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (true) { + val read = read(buffer) + if (read == -1) break + total += read + if (total > maxBytes) return null + output.write(buffer, 0, read) + } + return output.toByteArray() + } + + private fun InputStream.readExactBytesOrNull(size: Int): ByteArray? { + if (size < 0) return null + val bytes = ByteArray(size) + var offset = 0 + while (offset < size) { + val read = read(bytes, offset, size - offset) + if (read == -1) return null + offset += read + } + return bytes + } + + private fun InputStream.skipFully(bytes: Long): Boolean { + var remaining = bytes + val scratch = ByteArray(DEFAULT_BUFFER_SIZE) + while (remaining > 0L) { + val skipped = skip(remaining) + if (skipped > 0L) { + remaining -= skipped + continue + } + + val read = read(scratch, 0, minOf(scratch.size.toLong(), remaining).toInt()) + if (read == -1) return false + remaining -= read + } + return true + } + + private fun String.decodeBase64OrNull(): ByteArray? { + return runCatching { + Base64.getMimeDecoder().decode(this) + }.getOrNull() + } + + private fun String.attr(name: String): String { + return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.get(1) + .orEmpty() + } + + private fun String.tagText(tag: String): String? { + return Regex( + "<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) + .find(this) + ?.groupValues + ?.get(1) + ?.replace(Regex("<[^>]+>"), " ") + ?.decodeEntities() + ?.replace(Regex("\\s+"), " ") + ?.trim() + ?.takeIf { it.isNotBlank() } + } + + private fun String.tagInnerContent(tag: String): String? { + return Regex( + "<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) + .find(this) + ?.groupValues + ?.get(1) + ?.decodeEntities() + ?.trim() + ?.takeIf { it.isNotBlank() } + } + + private fun String.metaContent(name: String): String? { + return Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(this) + .firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) } + ?.value + ?.attr("content") + ?.decodeEntities() + ?.trim() + ?.takeIf { it.isNotBlank() } + } + + private fun String.decodeEntities(): String { + return replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(Regex("&#x([0-9a-fA-F]+);")) { match -> + match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty() + } + .replace(Regex("&#(\\d+);")) { match -> + match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty() + } + } + + private fun normalizeZipPath(path: String): String { + val parts = ArrayDeque() + path.replace('\\', '/').trimStart('/').split('/').forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + return parts.joinToString("/") + } + + private fun resolveEpubZipPath(basePath: String, href: String): String { + return normalizeZipPath(if (href.startsWith('/')) href else basePath + href) + } + + private fun String.percentDecodedOrSelf(): String { + return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) + } + + private fun String.localXmlName(): String = substringAfter(':').lowercase() + + private fun String.rasterExtension(): String? { + val extension = substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension.takeIf { it in rasterCoverExtensions } + } + + private fun extensionFromMimeType(mimeType: String?): String? { + return when (mimeType?.lowercase()) { + "image/jpeg", "image/jpg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/bmp" -> "bmp" + else -> null + } + } + + private fun ByteArray.rasterExtensionFromMagic(): String? { + return when { + size >= 3 && + (this[0].toInt() and 0xFF) == 0xFF && + (this[1].toInt() and 0xFF) == 0xD8 && + (this[2].toInt() and 0xFF) == 0xFF -> "jpg" + size >= 8 && asciiAt(1, 3) == "PNG" -> "png" + size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "gif" + size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "webp" + size >= 2 && asciiAt(0, 2) == "BM" -> "bmp" + else -> null + } + } + + private fun ByteArray.u16(offset: Int): Int { + if (offset + 2 > size) return 0 + return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF) + } + + private fun ByteArray.u32(offset: Int): Long { + if (offset + 4 > size) return 0 + return ((this[offset].toLong() and 0xFF) shl 24) or + ((this[offset + 1].toLong() and 0xFF) shl 16) or + ((this[offset + 2].toLong() and 0xFF) shl 8) or + (this[offset + 3].toLong() and 0xFF) + } + + private fun ByteArray.asciiAt(offset: Int, length: Int): String { + if (offset < 0 || offset + length > size) return "" + return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII) + } + + private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? { + if (offset < 0 || length <= 0 || offset + length > size) return null + return copyOfRange(offset, offset + length).toString(charset) + .trim('\u0000', ' ', '\n', '\r', '\t') + .takeUnlessBlank() + } + + private fun String?.takeUnlessBlank(): String? { + return this?.trim()?.takeIf { it.isNotBlank() } + } + + private val EpubManifestItem.rasterExtension: String? + get() { + href.rasterExtension()?.let { return it } + return extensionFromMimeType(mediaType) + } + + private data class ZipTextEntry( + val path: String, + val text: String + ) + + private data class EpubManifestItem( + val id: String, + val href: String, + val mediaType: String, + val properties: String + ) + + private data class MobiHeaderInfo( + val encoding: Int? = null, + val imageIndex: Int? = null + ) + + private data class MobiRecordScan( + val offsets: List, + val headerRecord: ByteArray + ) + + private data class MobiExthMetadata( + val title: String? = null, + val author: String? = null, + val coverOffset: Int? = null + ) +} diff --git a/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt b/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt new file mode 100644 index 0000000..f8f1d8e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt @@ -0,0 +1,115 @@ +package com.aryan.reader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.aryan.reader.data.BookMetadataEdit +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.shared.reader.SharedEpubMetadataEditor +import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot +import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +data class AndroidEpubMetadataEditResult( + val metadata: SharedEpubMetadataSnapshot, + val fileSize: Long, + val fileContentModifiedTimestamp: Long +) + +class EpubMetadataFileEditor(private val context: Context) { + suspend fun writeMetadata( + item: RecentFileItem, + metadata: BookMetadataEdit + ): Result = withContext(Dispatchers.IO) { + runCatching { + require(item.type == FileType.EPUB) { "Only EPUB metadata editing is supported." } + val sourceUri = item.uriString?.toUri() ?: error("Book file is not available.") + val token = UUID.randomUUID().toString() + val sourceCopy = File(context.cacheDir, "epub_metadata_source_$token.epub") + val editedCopy = File(context.cacheDir, "epub_metadata_edited_$token.epub") + + try { + copyUriToFile(sourceUri, sourceCopy) + val rewritten = SharedEpubMetadataEditor.rewrite( + source = sourceCopy, + destination = editedCopy, + update = SharedEpubMetadataUpdate( + title = metadata.title, + author = metadata.author, + description = metadata.description, + seriesName = metadata.seriesName, + seriesIndex = metadata.seriesIndex + ) + ) + backupOriginalIfNeeded(item, sourceCopy) + replaceUriBytes(sourceUri, editedCopy) + + AndroidEpubMetadataEditResult( + metadata = rewritten, + fileSize = queryFileSize(sourceUri).takeIf { it > 0L } ?: editedCopy.length(), + fileContentModifiedTimestamp = queryLastModified(sourceUri).takeIf { it > 0L } + ?: System.currentTimeMillis() + ) + } finally { + sourceCopy.delete() + editedCopy.delete() + } + } + } + + private fun copyUriToFile(uri: Uri, destination: File) { + context.contentResolver.openInputStream(uri)?.use { input -> + destination.outputStream().use { output -> input.copyTo(output) } + } ?: error("Unable to read EPUB source.") + } + + private fun replaceUriBytes(uri: Uri, editedFile: File) { + if (uri.scheme == "file") { + val target = File(uri.path ?: error("Invalid file URI.")) + editedFile.inputStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + return + } + + context.contentResolver.openOutputStream(uri, "wt")?.use { output -> + editedFile.inputStream().use { input -> input.copyTo(output) } + } ?: error("Unable to write EPUB source.") + } + + private fun backupOriginalIfNeeded(item: RecentFileItem, sourceCopy: File) { + val backupFile = File( + File(context.filesDir, "metadata_backups").apply { mkdirs() }, + "${item.bookId.toSafeBackupName()}.epub" + ) + if (!backupFile.exists()) { + sourceCopy.inputStream().use { input -> + backupFile.outputStream().use { output -> input.copyTo(output) } + } + } + } + + private fun queryFileSize(uri: Uri): Long { + return if (uri.scheme == "file") { + uri.path?.let { File(it).length() } ?: 0L + } else { + DocumentFile.fromSingleUri(context, uri)?.length() ?: 0L + } + } + + private fun queryLastModified(uri: Uri): Long { + return if (uri.scheme == "file") { + uri.path?.let { File(it).lastModified() } ?: 0L + } else { + DocumentFile.fromSingleUri(context, uri)?.lastModified() ?: 0L + } + } +} + +private fun String.toSafeBackupName(): String { + return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } +} diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt index 272691f..d5bd87b 100644 --- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt +++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt @@ -1,130 +1,66 @@ package com.aryan.reader -private val codeOrDataExtensions = setOf( - "csv", - "tsv", - "json", - "xml", - "log", - "java", - "kt", - "py", - "js", - "cpp", - "c", - "cs", - "rb", - "go" -) - -private val manualOnlyReaderMimeTypes = setOf( - "text/csv", - "text/comma-separated-values", - "text/tab-separated-values", - "application/json", - "application/xml", - "text/xml", - "text/x-java-source", - "text/x-python", - "text/x-kotlin", - "text/javascript", - "application/javascript", - "text/x-c", - "text/x-c++", - "text/x-csharp", - "text/x-ruby", - "text/x-go", - "text/x-log" -) +import com.aryan.reader.shared.SharedFileCapabilities internal fun resolveFileTypeFromName(fileName: String?): FileType? { - val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null - val effectiveName = lowerName.withTransparentTextSuffix() + return SharedFileCapabilities.resolveFileTypeForName(fileName) +} - return when { - effectiveName.endsWith(".cbz") -> FileType.CBZ - effectiveName.endsWith(".cbr") -> FileType.CBR - effectiveName.endsWith(".cb7") -> FileType.CB7 - effectiveName.endsWith(".pdf") -> FileType.PDF - effectiveName.endsWith(".epub") -> FileType.EPUB - effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI - effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2 - effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD - effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML - effectiveName.endsWith(".docx") -> FileType.DOCX - effectiveName.endsWith(".odt") -> FileType.ODT - effectiveName.endsWith(".fodt") -> FileType.FODT - effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML - effectiveName.endsWith(".txt") -> FileType.TXT - else -> null +internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? { + val normalizedMimeType = mimeType + ?.substringBefore(';') + ?.trim() + ?.lowercase() + return when (normalizedMimeType) { + "application/vnd.oasis.opendocument.text" -> FileType.ODT + "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX + "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX + "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> { + when { + fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ + fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2 + else -> null + } + } + "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> { + if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null + } + "application/x-cb7", "application/x-7z-compressed" -> { + if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null + } + "application/pdf" -> FileType.PDF + "application/epub+zip" -> FileType.EPUB + "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 + "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI + "text/markdown", "text/x-markdown" -> FileType.MD + "text/html", "application/xhtml+xml" -> FileType.HTML + "text/csv", "text/comma-separated-values", "text/tab-separated-values", + "application/json", "application/xml", "text/xml", + "text/x-java-source", "text/x-python", "text/x-kotlin", + "text/javascript", "application/javascript", + "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML + "text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT + else -> resolveFileTypeFromName(fileName) } } internal fun isCodeOrDataFileName(fileName: String): Boolean { - return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions + return SharedFileCapabilities.isCodeOrDataFileName(fileName) } internal fun isManualOnlyReaderFileName(fileName: String?): Boolean { - val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false - return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions + return SharedFileCapabilities.isManualOnlyReaderFileName(fileName) } internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean { - val normalized = mimeType?.lowercase() ?: return false - return normalized in manualOnlyReaderMimeTypes + return SharedFileCapabilities.isManualOnlyReaderMimeType(mimeType) } internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean { - if (isManualOnlyReaderFileName(name)) return false - if (resolveFileTypeFromName(name) != null) return true - return !isManualOnlyReaderMimeType(mimeType) + return SharedFileCapabilities.isLocalFolderSyncEligibleFile(name, mimeType) } internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? { - val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null - val effectiveName = lowerName.withTransparentTextSuffix() - val effectiveSuffix = when { - effectiveName.endsWith(".fb2.zip") -> ".fb2.zip" - effectiveName.endsWith(".markdown") -> ".markdown" - effectiveName.endsWith(".xhtml") -> ".xhtml" - effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}" - else -> null - } ?: return null - - return if (effectiveName != lowerName && lowerName.endsWith(".txt")) { - "$effectiveSuffix.txt" - } else { - effectiveSuffix - } -} - -private fun String.withTransparentTextSuffix(): String { - if (!endsWith(".txt")) return this - val innerName = removeSuffix(".txt") - if (innerName.isBlank() || !innerName.contains('.')) return this - return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this -} - -private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? { - return when { - fileName.endsWith(".cbz") -> FileType.CBZ - fileName.endsWith(".cbr") -> FileType.CBR - fileName.endsWith(".cb7") -> FileType.CB7 - fileName.endsWith(".pdf") -> FileType.PDF - fileName.endsWith(".epub") -> FileType.EPUB - fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI - fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2 - fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD - fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML - fileName.endsWith(".docx") -> FileType.DOCX - fileName.endsWith(".odt") -> FileType.ODT - fileName.endsWith(".fodt") -> FileType.FODT - fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML - else -> null - } -} - -private fun String.extensionAfterLastDot(): String? { - val dotIndex = lastIndexOf('.') - return if (dotIndex in 0.. 0L && size > 0L && existingItem.fileSize != size) { - Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).") + val modifiedChanged = lastModified > 0L && + existingItem.fileContentModifiedTimestamp != lastModified + if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) { + Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.") recentFilesRepository.clearLocalCachesForBook(stableId) updatedItem = updatedItem.copy( fileSize = size, + fileContentModifiedTimestamp = lastModified, lastModifiedTimestamp = lastModified, - folderTextMetadataParsed = false + coverImagePath = null, + title = name.substringBeforeLast('.', name), + author = null, + seriesName = null, + seriesIndex = null, + description = null, + originalTitle = null, + originalAuthor = null, + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null, + folderTextMetadataParsed = false, + folderCoverMetadataParsed = false ) needsUpdate = true } @@ -500,7 +516,7 @@ class FolderSyncWorker( if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) { if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) { - ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString") + ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString") val metaRequest = OneTimeWorkRequestBuilder() .setInputData( androidx.work.Data.Builder() @@ -514,7 +530,7 @@ class FolderSyncWorker( metaRequest ) } else { - ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString") + ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString") } } @@ -602,15 +618,7 @@ class FolderSyncWorker( } private fun getFileType(name: String, mimeType: String?): FileType? { - return when (mimeType) { - "application/pdf" -> FileType.PDF - "application/epub+zip" -> FileType.EPUB - "application/vnd.oasis.opendocument.text" -> FileType.ODT - "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX - "text/html", "application/xhtml+xml" -> FileType.HTML - else -> resolveFileTypeFromName(name) - } + return resolveFileTypeFromMetadata(name, mimeType) } private fun buildStableBookId(name: String, rootDocId: String, docId: String): String { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index dc9912b..b3aeb74 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.FavoriteBorder @@ -140,10 +141,8 @@ import androidx.navigation.NavHostController import coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber -import java.io.File import java.text.SimpleDateFormat import java.util.Locale @@ -171,15 +170,16 @@ fun HomeScreen( CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val screenModel = remember(uiState) { uiState.toHomeScreenModel() } - val recentFilesForHome = screenModel.recentFiles - val openTabs = screenModel.openTabs - val selectedContextItems = screenModel.selectedItems - val isContextualModeActive = screenModel.isContextualModeActive + val recentFilesForHome = uiState.recentFiles + val openTabs = uiState.openTabs + val selectedContextItems = uiState.contextualActionItems + val isContextualModeActive = selectedContextItems.isNotEmpty() + val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty()) + val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty() val scope = rememberCoroutineScope() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val snackbarHostState = remember { SnackbarHostState() } - val deviceLimitState = screenModel.deviceLimitState + val deviceLimitState = uiState.deviceLimitState var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showClearCloudDataDialog by remember { mutableStateOf(false) } @@ -224,15 +224,6 @@ fun HomeScreen( } } - LaunchedEffect(uiState.bannerMessage) { - uiState.bannerMessage?.let { msg -> - if (!msg.isPersistent) { - delay(3000L) - viewModel.bannerMessageShown() - } - } - } - LaunchedEffect(uiState.errorMessage) { uiState.errorMessage?.let { message -> snackbarHostState.showSnackbar(message) @@ -319,6 +310,12 @@ fun HomeScreen( navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) } }, + onSettingsClick = { + scope.launch { + drawerState.close() + navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) + } + }, navController = navController, onFolderSyncToggle = viewModel::setFolderSyncEnabled ) @@ -353,6 +350,9 @@ fun HomeScreen( } }, onAppThemeClick = { showAppThemePanel = true }, + onSettingsClick = { + navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) + }, onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, onLanguageClick = { showLanguageDialog = true }, @@ -394,8 +394,8 @@ fun HomeScreen( .fillMaxSize() .padding(paddingValues) ) { - if (screenModel.isEmpty) { - if (screenModel.isLibraryEmpty) { + if (isHomeEmpty) { + if (isLibraryEmpty) { EmptyState( title = stringResource(R.string.your_library_empty), message = stringResource(R.string.your_library_empty_desc), @@ -502,8 +502,14 @@ fun HomeScreen( showInfoDialog = false itemForInfoDialog = null }, - onUpdateName = { newName -> - viewModel.updateCustomName(item.bookId, newName) + onSaveMetadata = { metadata -> + viewModel.updateBookMetadata(item.bookId, metadata) + }, + onSaveDisplayName = { name -> + viewModel.updateCustomName(item.bookId, name) + }, + onRestoreMetadata = { + viewModel.restoreOriginalBookMetadata(item.bookId) }, onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) @@ -793,16 +799,8 @@ fun RecentFileCard( onLongClick: () -> Unit, isDownloading: Boolean, ) { - val context = LocalContext.current val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " - 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, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder - } - val imageModel = remember(item.coverImagePath) { - item.coverImagePath?.let { File(it) } ?: placeholder - } androidx.compose.material3.ElevatedCard( modifier = modifier @@ -827,24 +825,25 @@ fun RecentFileCard( .fillMaxWidth() .aspectRatio(0.74f) ) { - AsyncImage( - model = ImageRequest.Builder(context).data(imageModel).error(placeholder) - .fallback(placeholder).crossfade(true).build(), + ThemedBookCover( + item = item, contentDescription = item.displayName, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - Box( - modifier = Modifier.fillMaxSize().background( - androidx.compose.ui.graphics.Brush.verticalGradient( - 0f to Color.Black.copy(alpha = 0.15f), - 0.3f to Color.Transparent, - 0.6f to Color.Transparent, - 1f to Color.Black.copy(alpha = 0.5f) + if (!item.coverImagePath.isNullOrBlank()) { + Box( + modifier = Modifier.fillMaxSize().background( + androidx.compose.ui.graphics.Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.15f), + 0.3f to Color.Transparent, + 0.6f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.5f) + ) ) ) - ) + } if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) { FileStatusBadges( @@ -1024,6 +1023,7 @@ fun DefaultTopAppBar( onExternalFileBehaviorClick: () -> Unit, onStrictFilterToggleClick: () -> Unit, onAppThemeClick: () -> Unit, + onSettingsClick: () -> Unit, onTestPanelDetectionClick: () -> Unit, onTestSpeechBubbleDetectionClick: () -> Unit, onLanguageClick: () -> Unit, @@ -1048,6 +1048,9 @@ fun DefaultTopAppBar( } } }, actions = { + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } Box { IconButton(onClick = onAppThemeClick) { Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme)) @@ -1134,19 +1137,21 @@ fun DefaultTopAppBar( showOptionsMenu = false }) - DropdownMenuItem( - text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") }, - onClick = { - onToggleHideReaderAi() - hideReaderAiFeatures = !hideReaderAiFeatures - showOptionsMenu = false - }, - trailingIcon = { - if (hideReaderAiFeatures) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + if (!BuildConfig.IS_OFFLINE) { + DropdownMenuItem( + text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") }, + onClick = { + onToggleHideReaderAi() + hideReaderAiFeatures = !hideReaderAiFeatures + showOptionsMenu = false + }, + trailingIcon = { + if (hideReaderAiFeatures) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } } - } - ) + ) + } HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { @@ -1204,6 +1209,7 @@ private fun AppDrawerContent( onSyncUpsellClick: () -> Unit, onFontsClick: () -> Unit, onAiSettingsClick: () -> Unit, + onSettingsClick: () -> Unit, navController: NavHostController, onFolderSyncToggle: (Boolean) -> Unit ) { @@ -1366,6 +1372,14 @@ private fun AppDrawerContent( HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) } + NavigationDrawerItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text("Settings") }, + selected = false, + onClick = onSettingsClick, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + NavigationDrawerItem( icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) }, label = { Text(stringResource(R.string.drawer_custom_fonts)) }, diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt index f2bd2b4..627e57e 100644 --- a/app/src/main/java/com/aryan/reader/LibraryModels.kt +++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt @@ -1,40 +1,21 @@ package com.aryan.reader import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities -enum class AddBooksSource { - UNSHELVED, - ALL_BOOKS -} +typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource +typealias FileType = com.aryan.reader.shared.FileType +typealias RenderMode = com.aryan.reader.shared.RenderMode +typealias SortOrder = com.aryan.reader.shared.SortOrder +typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter +typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters +typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder -enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT -} - -internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7) - -internal val EPUB_READER_FILE_TYPES = setOf( - FileType.EPUB, - FileType.MOBI, - FileType.MD, - FileType.TXT, - FileType.HTML, - FileType.FB2, - FileType.DOCX, - FileType.ODT, - FileType.FODT -) - -enum class RenderMode { - VERTICAL_SCROLL, PAGINATED -} - -data class SyncedFolder( - val uriString: String, - val name: String, - val lastScanTime: Long, - val allowedFileTypes: Set = FileType.entries.toSet() -) +internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) +internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID) +internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES +internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } @@ -54,33 +35,3 @@ data class Shelf( val directBookCount: Int get() = directBooks.size val childShelfCount: Int get() = childShelfIds.size } - -enum class SortOrder { - RECENT, - TITLE_ASC, - AUTHOR_ASC, - PERCENT_ASC, - PERCENT_DESC, - SIZE_ASC, - SIZE_DESC -} - -enum class ReadStatusFilter { - ALL, - UNREAD, - IN_PROGRESS, - COMPLETED -} - -data class LibraryFilters( - val fileTypes: Set = emptySet(), - val sourceFolders: Set = emptySet(), - val readStatus: ReadStatusFilter = ReadStatusFilter.ALL, - val tagIds: Set = emptySet() -) { - val isActive: Boolean - get() = fileTypes.isNotEmpty() || - sourceFolders.isNotEmpty() || - readStatus != ReadStatusFilter.ALL || - tagIds.isNotEmpty() -} diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 0b619e2..5559eff 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -79,6 +79,7 @@ import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip @@ -133,6 +134,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi +import androidx.navigation.NavHostController import coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem @@ -148,7 +150,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.jsoup.Jsoup import timber.log.Timber -import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -163,6 +164,7 @@ private fun getBookCountString(count: Int): String { @Composable fun LibraryScreen( viewModel: MainViewModel, + navController: NavHostController, ) { val compStart = remember { System.currentTimeMillis() } LaunchedEffect(Unit) { @@ -170,14 +172,13 @@ fun LibraryScreen( } val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val screenModel = remember(uiState) { uiState.toLibraryScreenModel() } - val selectedItems = screenModel.selectedItems - val isContextualModeActive = screenModel.isContextualModeActive - val selectedShelves = screenModel.selectedShelves - val isShelfContextualModeActive = screenModel.isShelfContextualModeActive - val sortOrder = screenModel.sortOrder - val shelves = screenModel.shelves - val rawLibraryFiles = screenModel.rawLibraryFiles + val selectedItems = uiState.contextualActionItems + val isContextualModeActive = selectedItems.isNotEmpty() + val selectedShelves = uiState.contextualActionShelfIds + val isShelfContextualModeActive = selectedShelves.isNotEmpty() + val sortOrder = uiState.sortOrder + val shelves = uiState.shelves + val rawLibraryFiles = uiState.rawLibraryFiles val tabTitles = remember { buildList { add(context.getString(R.string.tab_all_books)) @@ -193,13 +194,13 @@ fun LibraryScreen( pageCount = { tabTitles.size } ) - val containsFolderItems = screenModel.containsFolderItemsInSelection + val containsFolderItems = selectedItems.any { it.sourceFolderUri != null } val scope = rememberCoroutineScope() var showFilterSheet by remember { mutableStateOf(false) } - val isSearchActive = screenModel.isSearchActive - val searchQuery = screenModel.searchQuery + val isSearchActive = uiState.isSearchActive + val searchQuery = uiState.searchQuery val pickFolderLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree() @@ -341,7 +342,8 @@ fun LibraryScreen( catalogId = catalog?.id ) }, - onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog + onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog, + onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) } ) @@ -394,8 +396,14 @@ fun LibraryScreen( showInfoDialog = false itemForInfoDialog = null }, - onUpdateName = { newName -> - viewModel.updateCustomName(item.bookId, newName) + onSaveMetadata = { metadata -> + viewModel.updateBookMetadata(item.bookId, metadata) + }, + onSaveDisplayName = { name -> + viewModel.updateCustomName(item.bookId, name) + }, + onRestoreMetadata = { + viewModel.restoreOriginalBookMetadata(item.bookId) }, onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) @@ -516,7 +524,9 @@ fun ShelfScreen( FileInfoDialog( item = item, onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, - onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) }, + onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, + onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) }, + onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) }, onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) } @@ -577,6 +587,7 @@ fun LibraryScreenContent( onOpdsBookDownloaded: (Uri, String) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onDeleteCatalogStreams: (String) -> Unit, + onSettingsClick: () -> Unit, ) { val isBookContextualModeActive = selectedItems.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty() @@ -711,6 +722,9 @@ fun LibraryScreenContent( Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) } } + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } } ) TabRow(selectedTabIndex = pagerState.currentPage) { @@ -1442,8 +1456,6 @@ private fun AddBooksModeScreen( @Composable private fun ShelfCover(shelf: Shelf) { - val context = LocalContext.current - val placeholder = R.drawable.epub_placeholder val booksForCovers = shelf.books.take(4).reversed() val coverWidth = 52.dp val coverHeight = 75.dp @@ -1457,22 +1469,24 @@ private fun ShelfCover(shelf: Shelf) { contentAlignment = Alignment.CenterStart ) { if (booksForCovers.size <= 1) { - val imageModel = remember(shelf.topBook?.coverImagePath) { - shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder + val topBook = shelf.topBook + if (topBook != null) { + ThemedBookCover( + item = topBook, + contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = coverWidth, height = coverHeight) + .clip(MaterialTheme.shapes.small) + ) + } else { + EmptyShelfCover( + shelfName = shelf.name, + modifier = Modifier + .size(width = coverWidth, height = coverHeight) + .clip(MaterialTheme.shapes.small) + ) } - AsyncImage( - model = ImageRequest.Builder(context) - .data(imageModel) - .error(placeholder) - .fallback(placeholder) - .crossfade(true) - .build(), - contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(width = coverWidth, height = coverHeight) - .clip(MaterialTheme.shapes.small) - ) } else { Box( modifier = Modifier @@ -1480,9 +1494,6 @@ private fun ShelfCover(shelf: Shelf) { .height(coverHeight) ) { booksForCovers.forEachIndexed { index, book -> - val imageModel = remember(book.coverImagePath) { - book.coverImagePath?.let { File(it) } ?: placeholder - } Surface( shape = MaterialTheme.shapes.small, shadowElevation = 4.dp, @@ -1491,13 +1502,8 @@ private fun ShelfCover(shelf: Shelf) { .align(Alignment.CenterEnd) .offset(x = -horizontalOffset * index) ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(imageModel) - .error(placeholder) - .fallback(placeholder) - .crossfade(true) - .build(), + ThemedBookCover( + item = book, contentDescription = null, contentScale = ContentScale.Crop ) @@ -1508,6 +1514,36 @@ private fun ShelfCover(shelf: Shelf) { } } +@Composable +private fun EmptyShelfCover( + shelfName: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background( + androidx.compose.ui.graphics.Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) + ) + .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + Text( + text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp) + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun ShelfListItem( @@ -1596,15 +1632,6 @@ private fun LibraryListItem( onItemLongClick: () -> Unit, isDownloading: Boolean, ) { - 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, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder - } - val imageModel = remember(item.coverImagePath) { - item.coverImagePath?.let { File(it) } ?: placeholder - } - androidx.compose.material3.ElevatedCard( shape = MaterialTheme.shapes.large, colors = androidx.compose.material3.CardDefaults.elevatedCardColors( @@ -1641,13 +1668,8 @@ private fun LibraryListItem( .clip(MaterialTheme.shapes.medium) .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium) ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(imageModel) - .error(placeholder) - .fallback(placeholder) - .crossfade(true) - .build(), + ThemedBookCover( + item = item, contentDescription = item.displayName, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() @@ -2148,7 +2170,7 @@ private fun EditFolderFiltersDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - FileType.entries.forEach { type -> + ANDROID_SYNCABLE_FILE_TYPES.forEach { type -> val isSelected = type in selectedTypes FilterChip( selected = isSelected, @@ -2226,7 +2248,7 @@ fun LibraryFilterSheet( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - FileType.entries.forEach { type -> + ANDROID_READABLE_FILE_TYPES.forEach { type -> FilterChip( selected = type in currentFilters.fileTypes, onClick = { diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt index 323fb80..004dbde 100644 --- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt +++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt @@ -5,7 +5,10 @@ import com.aryan.reader.data.BookTagCrossRef import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.ShelfEntity import com.aryan.reader.data.TagEntity -import com.aryan.reader.shared.SmartCollectionEngine +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters +import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch +import com.aryan.reader.shared.sortBooks as sharedSortBooks fun interface FolderPathResolver { fun relativeFolderSegments(item: RecentFileItem): List @@ -32,17 +35,28 @@ class LibraryStateProjector( fun project(input: LibraryProjectionInput): ReaderScreenState { val start = ReaderPerfLog.nowNanos() val internalState = input.state + val bridgeContext = AndroidSharedStateBridge.prepareLibraryProjection(input, folderPathResolver) val cacheKey = ProjectionCacheKey( recentFilesFromDb = input.recentFilesFromDb, dbShelves = input.dbShelves, shelfRefs = input.shelfRefs, dbTags = input.dbTags, tagRefs = input.tagRefs, - folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) }, + folderKeys = bridgeContext.folderKeys, sortOrder = internalState.sortOrder, searchQuery = internalState.searchQuery, libraryFilters = internalState.libraryFilters, - recentFilesLimit = internalState.recentFilesLimit + recentFilesLimit = internalState.recentFilesLimit, + openTabIds = internalState.openTabIds, + activeTabBookId = internalState.activeTabBookId, + selectedBookIds = internalState.contextualActionItems.mapTo(mutableSetOf()) { it.bookId }, + selectedShelfIds = internalState.contextualActionShelfIds, + viewingShelfId = internalState.viewingShelfId, + isAddingBooksToShelf = internalState.isAddingBooksToShelf, + addBooksSource = internalState.addBooksSource, + booksSelectedForAdding = internalState.booksSelectedForAdding, + pinnedHomeBookIds = internalState.pinnedHomeBookIds, + pinnedLibraryBookIds = internalState.pinnedLibraryBookIds ) cachedProjection?.takeIf { it.key == cacheKey }?.let { cache -> @@ -50,71 +64,26 @@ class LibraryStateProjector( val elapsed = ReaderPerfLog.elapsedMs(start) if (elapsed >= 8L) { ReaderPerfLog.d( - "LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}" + "LibraryProject cache-hit took ${elapsed}ms books=${cache.androidBooksById.size} shelves=${cache.projected.shelves.size}" ) } return result } - val tagsById = input.dbTags.associateBy { it.id } - val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry -> - entry.value.mapNotNull { tagsById[it.tagId] } - } - - val allLibraryFiles = input.recentFilesFromDb - .filterNot { it.bookId.endsWith("_reflow") } - .map { item -> - item.copy(tags = bookTagsMap[item.bookId] ?: emptyList()) - } - val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId } - - val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery) - val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters) - val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) { - libraryFiltered - } else { - sortFiles(libraryFiltered, internalState.sortOrder) - } - val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE - val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) { - allLibraryFiles - .asSequence() - .filter { it.isRecent } - .take(recentLimit) - .toList() - } else { - sortFiles( - allLibraryFiles.filter { it.isRecent }, - internalState.sortOrder - ).take(recentLimit) - } - - val shelfProjection = buildShelves( - allLibraryFiles = allLibraryFiles, - dbShelves = input.dbShelves, - shelfRefs = input.shelfRefs, - dbTags = input.dbTags, - sortOrder = internalState.sortOrder, - syncedFolders = internalState.syncedFolders - ) - + val projected = AndroidSharedStateBridge.projectLibrary(bridgeContext) val cache = CachedProjection( key = cacheKey, - allLibraryFiles = allLibraryFiles, - allLibraryFilesById = allLibraryFilesById, - sortedLibraryFiles = sortedLibraryFiles, - visibleRecentFiles = visibleRecentFiles, - shelfProjection = shelfProjection, - validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }, - dbTags = input.dbTags + projected = projected, + androidBooksById = bridgeContext.androidBooksById, + tagEntitiesById = bridgeContext.tagEntitiesById ) cachedProjection = cache val elapsed = ReaderPerfLog.elapsedMs(start) - if (elapsed >= 16L || allLibraryFiles.size >= 500) { + if (elapsed >= 16L || bridgeContext.androidBooksById.size >= 500) { ReaderPerfLog.d( - "LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " + - "visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " + + "LibraryProject shared recompute took ${elapsed}ms books=${bridgeContext.androidBooksById.size} " + + "visible=${projected.libraryBooks.size} shelves=${projected.shelves.size} " + "tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}" ) } @@ -126,310 +95,63 @@ class LibraryStateProjector( internalState: ReaderScreenState, cache: CachedProjection ): ReaderScreenState { - val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds } - val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds } - val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) { - val currentShelfBookIds = cache.shelfProjection.shelves - .find { it.id == viewingShelfId } - ?.books - ?.mapTo(mutableSetOf()) { it.bookId } - ?: emptySet() - when (internalState.addBooksSource) { - AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks - AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds } - } - } else { - emptyList() - } - - return internalState.copy( - recentFiles = cache.visibleRecentFiles, - allRecentFiles = cache.sortedLibraryFiles, - rawLibraryFiles = cache.allLibraryFiles, - viewingShelfId = viewingShelfId, - isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null, - contextualActionShelfIds = selectedShelfIds, - contextualActionItems = internalState.contextualActionItems - .mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] } - .toSet(), - shelves = cache.shelfProjection.shelves, - openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] }, - booksAvailableForAdding = booksAvailableForAdding, - allTags = cache.dbTags + return AndroidSharedStateBridge.toAndroidState( + base = internalState, + sharedState = cache.projected, + androidBooksById = cache.androidBooksById, + tagEntitiesById = cache.tagEntitiesById ) } - private fun buildShelves( - allLibraryFiles: List, - dbShelves: List, - shelfRefs: List, - dbTags: List, - sortOrder: SortOrder, - syncedFolders: List - ): ShelfProjection { - val allShelves = mutableListOf() - val shelvedBookIds = mutableSetOf() - val baseFilesMap = allLibraryFiles.associateBy { it.bookId } - val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId } - val taggedBookIdsByTagId = mutableMapOf>() - - allLibraryFiles.forEach { item -> - item.tags.forEach { tag -> - taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId) - } - } - - dbShelves.forEach { shelfEntity -> - if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) { - val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson) - if (rules != null) { - val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) } - allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder))) - shelvedBookIds.addAll(matchingBooks.map { it.bookId }) - } - } else { - val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty() - .sortedBy { it.addedAt } - .map { it.bookId } - val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] } - allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder))) - shelvedBookIds.addAll(bookIdsInShelf) - } - } - - val tagShelves = dbTags.mapNotNull { tag -> - val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] } - if (taggedBooks.isEmpty()) { - null - } else { - Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder)) - } - } - allShelves.addAll(tagShelves) - - val seriesShelves = allLibraryFiles - .filter { !it.seriesName.isNullOrBlank() } - .groupBy { it.seriesName!! } - .filter { it.value.size >= 2 } - .map { (series, books) -> - val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 } - shelvedBookIds.addAll(books.map { it.bookId }) - Shelf("series_$series", series, ShelfType.SERIES, sortedSeries) - } - allShelves.addAll(seriesShelves) - - val folderShelves = buildFolderShelves( - allLibraryFiles = allLibraryFiles, - syncedFolders = syncedFolders, - sortOrder = sortOrder - ).also { shelves -> - shelves.forEach { shelf -> - shelvedBookIds.addAll(shelf.books.map { it.bookId }) - } - } - allShelves.addAll(folderShelves) - - val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds } - allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder))) - - allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey })) - return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks) - } - - private fun buildFolderShelves( - allLibraryFiles: List, - syncedFolders: List, - sortOrder: SortOrder - ): List { - val folderNamesByUri = syncedFolders.associate { it.uriString to it.name } - val folderSegmentsByBookId = allLibraryFiles - .asSequence() - .filter { it.sourceFolderUri != null } - .associate { it.bookId to folderPathResolver.relativeFolderSegments(it) } - - return allLibraryFiles - .filter { it.sourceFolderUri != null } - .groupBy { it.sourceFolderUri!! } - .flatMap { (folderUri, books) -> - val rootName = folderNamesByUri[folderUri] ?: "Local Folder" - val rootShelfId = "folder_$folderUri" - val rootAccumulator = FolderShelfAccumulator( - id = rootShelfId, - name = rootName, - depth = 0, - parentShelfId = null, - sortPath = "" - ) - val rootShelf = Shelf( - id = rootShelfId, - name = rootName, - type = ShelfType.FOLDER, - books = sortFiles(books, sortOrder), - directBooks = emptyList(), - childShelfIds = emptyList(), - depth = 0, - sortKey = "folder:${rootName.lowercase()}:" - ) - - val nestedShelves = linkedMapOf() - val nestedShelvesById = mutableMapOf() - books.forEach { book -> - rootAccumulator.books.add(book) - val segments = folderSegmentsByBookId[book.bookId].orEmpty() - if (segments.isEmpty()) { - rootAccumulator.directBooks.add(book) - } - var currentPath = "" - var parentShelfId = rootShelfId - segments.forEachIndexed { index, segment -> - currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment" - val shelfId = "folder_$folderUri::$currentPath" - val accumulator = nestedShelves.getOrPut(currentPath) { - val newShelf = FolderShelfAccumulator( - id = shelfId, - name = segment, - depth = index + 1, - parentShelfId = parentShelfId, - sortPath = currentPath.lowercase() - ) - if (parentShelfId == rootShelfId) { - rootAccumulator.childShelfIds.add(shelfId) - } else { - nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId) - } - nestedShelvesById[shelfId] = newShelf - newShelf - } - accumulator.books.add(book) - if (index == segments.lastIndex) { - accumulator.directBooks.add(book) - } - parentShelfId = shelfId - } - } - - val sortedNestedShelves = nestedShelves - .values - .sortedBy { it.sortPath } - .map { shelf -> - Shelf( - id = shelf.id, - name = shelf.name, - type = ShelfType.FOLDER, - books = sortFiles(shelf.books, sortOrder), - directBooks = sortFiles(shelf.directBooks, sortOrder), - parentShelfId = shelf.parentShelfId, - childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }, - depth = shelf.depth, - sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}" - ) - } - - listOf( - rootShelf.copy( - directBooks = sortFiles(rootAccumulator.directBooks, sortOrder), - childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() } - ) - ) + sortedNestedShelves - } - } - - private data class FolderShelfAccumulator( - val id: String, - val name: String, - val depth: Int, - val parentShelfId: String?, - val sortPath: String, - val books: MutableList = mutableListOf(), - val directBooks: MutableList = mutableListOf(), - val childShelfIds: MutableList = mutableListOf() - ) - - private data class ShelfProjection( - val shelves: List, - val unshelvedBooks: List - ) - private data class ProjectionCacheKey( val recentFilesFromDb: List, val dbShelves: List, val shelfRefs: List, val dbTags: List, val tagRefs: List, - val folderKeys: List, + val folderKeys: List, val sortOrder: SortOrder, val searchQuery: String, val libraryFilters: LibraryFilters, - val recentFilesLimit: Int - ) - - private data class SyncedFolderProjectionKey( - val uriString: String, - val name: String + val recentFilesLimit: Int, + val openTabIds: List, + val activeTabBookId: String?, + val selectedBookIds: Set, + val selectedShelfIds: Set, + val viewingShelfId: String?, + val isAddingBooksToShelf: Boolean, + val addBooksSource: AddBooksSource, + val booksSelectedForAdding: Set, + val pinnedHomeBookIds: Set, + val pinnedLibraryBookIds: Set ) private data class CachedProjection( val key: ProjectionCacheKey, - val allLibraryFiles: List, - val allLibraryFilesById: Map, - val sortedLibraryFiles: List, - val visibleRecentFiles: List, - val shelfProjection: ShelfProjection, - val validShelfIds: Set, - val dbTags: List + val projected: SharedReaderScreenState, + val androidBooksById: Map, + val tagEntitiesById: Map ) } fun filterBySearch(files: List, searchQuery: String): List { - val query = searchQuery.trim() - return if (query.isBlank()) { - files - } else { - files.filter { item -> - item.displayName.contains(query, ignoreCase = true) || - item.title?.contains(query, ignoreCase = true) == true || - item.author?.contains(query, ignoreCase = true) == true || - item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) } - } - } + return files.mapSharedResults(sharedFilterBySearch(files.map { it.toSharedProjectionBookItem() }, searchQuery)) } fun applyLibraryFilters(files: List, filters: LibraryFilters): List { - return files.filter { item -> - val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true - val matchFolder = if (filters.sourceFolders.isNotEmpty()) { - val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && - item.sourceFolderUri == null && - item.uriString?.startsWith("opds-pse") != true - val matchesSynced = item.sourceFolderUri in filters.sourceFolders - matchesInApp || matchesSynced - } else { - true - } - val progress = item.progressPercentage ?: 0f - val matchStatus = when (filters.readStatus) { - ReadStatusFilter.ALL -> true - ReadStatusFilter.UNREAD -> progress == 0f - ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f - ReadStatusFilter.COMPLETED -> progress >= 100f - } - val matchTags = if (filters.tagIds.isNotEmpty()) { - item.tags.any { it.id in filters.tagIds } - } else { - true - } - matchType && matchFolder && matchStatus && matchTags - } + return files.mapSharedResults( + sharedApplyLibraryFilters( + books = files.map { it.toSharedProjectionBookItem() }, + filters = filters.toSharedLibraryFilters() + ) + ) } fun sortFiles(files: List, sortOrder: SortOrder): List { - return when (sortOrder) { - SortOrder.RECENT -> files.sortedByDescending { it.timestamp } - SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } - 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 } - } + return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder())) +} + +private fun List.mapSharedResults(sharedBooks: List): List { + val byId = associateBy { it.bookId } + return sharedBooks.mapNotNull { byId[it.id] } } diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index 3033ca8..da7e01e 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -121,9 +121,6 @@ class MainActivity : AppCompatActivity() { } } } - if (BuildConfig.DEBUG) { - WebView.setWebContentsDebuggingEnabled(true) - } } override fun onNewIntent(intent: Intent) { diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt index 7e5a4ff..c0c933d 100644 --- a/app/src/main/java/com/aryan/reader/MainScreen.kt +++ b/app/src/main/java/com/aryan/reader/MainScreen.kt @@ -110,7 +110,10 @@ fun MainScreen( windowSizeClass = windowSizeClass, navController = navController ) - 1 -> LibraryScreen(viewModel = viewModel) + 1 -> LibraryScreen( + viewModel = viewModel, + navController = navController + ) } } } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index dd58c74..9194d90 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -54,6 +54,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.aryan.reader.data.BookMetadata +import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.CloudflareRepository import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.FeedbackRepository @@ -80,6 +81,7 @@ import com.aryan.reader.epub.SingleFileImporter import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.ml.ISpeechBubbleDetector import com.aryan.reader.ml.SpeechBubble +import com.aryan.reader.ml.SpeechBubbleDetector import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker @@ -95,9 +97,13 @@ import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage +import com.aryan.reader.pptx.PptxCoverGenerator import com.aryan.reader.shared.SharedLibraryEditor -import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG +import com.aryan.reader.shared.SharedImportOutcomeCounts +import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec +import com.aryan.reader.shared.AppAction as SharedAppAction +import com.aryan.reader.shared.LibraryAction as SharedLibraryAction import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -107,10 +113,10 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -150,6 +156,8 @@ private data class CachedSpeechBubble( val maskBitmap: Bitmap? ) +private const val BANNER_AUTO_DISMISS_MILLIS = 3_000L + @kotlin.OptIn(ExperimentalSerializationApi::class) @UnstableApi open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -165,6 +173,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) } private val singleFileImporter by lazy { SingleFileImporter(appContext) } private val bookImporter by lazy { BookImporter(appContext) } + private val epubMetadataFileEditor by lazy { EpubMetadataFileEditor(appContext) } private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) } private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) } private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) } @@ -192,6 +201,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val _navigationEvent = Channel(Channel.BUFFERED) @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() + private var bannerDismissJob: Job? = null + private var bannerDismissGeneration = 0L private var pendingSwitchDeferred: CompletableDeferred? = null private var externalOpenedBookId: String? = null @@ -208,7 +219,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite") if (modelFile.exists()) { try { - val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector") + val clazz = Class.forName( + "com.aryan.reader.ml.ComicPanelDetector", + false, + context.classLoader + ) panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector } catch (e: Exception) { Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection") @@ -225,10 +240,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") if (modelFile.exists()) { try { - val clazz = Class.forName("com.aryan.reader.ml.SpeechBubbleDetector") - speechBubbleDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as ISpeechBubbleDetector + speechBubbleDetector = SpeechBubbleDetector(modelFile) } catch (t: Throwable) { - Timber.e(t, "Failed to instantiate SpeechBubbleDetector via reflection. Deleting corrupted model.") + Timber.e(t, "Failed to instantiate SpeechBubbleDetector. Deleting corrupted model.") modelFile.delete() } } else { @@ -519,7 +533,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio libraryFilters = LibraryFilters( fileTypes = prefs.getStringSet(KEY_FILTER_FILE_TYPES, emptySet())?.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() - }?.toSet() ?: emptySet(), + }?.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES } ?: emptySet(), sourceFolders = prefs.getStringSet(KEY_FILTER_FOLDERS, emptySet()) ?: emptySet(), readStatus = runCatching { ReadStatusFilter.valueOf(prefs.getString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) ?: ReadStatusFilter.ALL.name) @@ -534,7 +548,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(), pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(), recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0), - isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, false), + isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, true), openTabIds = prefs.getString(KEY_OPEN_TAB_IDS, null)?.let { try { val arr = JSONArray(it) @@ -634,14 +648,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialValue = _internalState.value ) + private fun ReaderScreenState.withSharedLibraryAction(action: SharedLibraryAction): ReaderScreenState { + return AndroidSharedStateBridge.reduceLibraryAction( + current = this, + projectedState = uiState.value, + action = action + ) + } + + private fun ReaderScreenState.withSharedAppAction(action: SharedAppAction): ReaderScreenState { + return AndroidSharedStateBridge.reduceAppAction( + current = this, + projectedState = uiState.value, + action = action + ) + } + fun setTabsEnabled(enabled: Boolean) { + val projectedState = uiState.value prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } - _internalState.update { it.copy(isTabsEnabled = enabled) } + _internalState.update { + AndroidSharedStateBridge.setTabsEnabled( + current = it, + projectedState = projectedState, + enabled = enabled + ) + } if (!enabled) { - val active = _internalState.value.activeTabBookId - val newTabs = if (active != null) listOf(active) else emptyList() - prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(newTabs).toString()) } - _internalState.update { it.copy(openTabIds = newTabs) } + persistTabState(_internalState.value.openTabIds, _internalState.value.activeTabBookId) } } @@ -652,21 +686,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return } - val currentTabs = _internalState.value.openTabIds.toMutableList() - if (!currentTabs.contains(bookId)) { - if (currentTabs.size >= 20) { + val currentState = _internalState.value + if (bookId !in currentState.openTabIds) { + if (currentState.openTabIds.size >= 20) { viewModelScope.launch(Dispatchers.Main) { showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) } return } - currentTabs.add(bookId) } + val tabState = AndroidSharedStateBridge.openBookTab( + current = currentState, + projectedState = uiState.value, + bookId = bookId + ) - prefs.edit { - putString(KEY_ACTIVE_TAB, bookId) - putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) - } + persistTabState(tabState.openTabIds, tabState.activeTabBookId) val uri = item.getUri() Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}") @@ -676,8 +711,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it") _internalState.update { state -> state.copy( - openTabIds = currentTabs, - activeTabBookId = bookId, + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId, selectedPdfUri = it, selectedBookId = bookId, selectedFileType = item.type, @@ -699,7 +735,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } } ?: run { - _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } + _internalState.update { + it.copy( + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId, + isTabsEnabled = tabState.isTabsEnabled + ) + } + } + } + + private fun persistTabState(openTabIds: List, activeTabBookId: String?) { + prefs.edit { + if (openTabIds.isEmpty()) { + remove(KEY_OPEN_TAB_IDS) + } else { + putString(KEY_OPEN_TAB_IDS, JSONArray(openTabIds).toString()) + } + if (activeTabBookId == null) { + remove(KEY_ACTIVE_TAB) + } else { + putString(KEY_ACTIVE_TAB, activeTabBookId) + } } } @@ -840,29 +897,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun closeTab(bookId: String) { Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId") - val currentTabs = _internalState.value.openTabIds.toMutableList() - currentTabs.remove(bookId) + val currentState = _internalState.value + val tabState = AndroidSharedStateBridge.closeBookTab( + current = currentState, + projectedState = uiState.value, + bookId = bookId + ) - if (currentTabs.isEmpty()) { - prefs.edit { - remove(KEY_OPEN_TAB_IDS) - remove(KEY_ACTIVE_TAB) + if (tabState.openTabIds.isEmpty()) { + persistTabState(tabState.openTabIds, tabState.activeTabBookId) + _internalState.update { + it.copy( + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId + ) } - _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } clearSelectedFile() } else { - val activeTab = _internalState.value.activeTabBookId + val activeTab = currentState.activeTabBookId if (activeTab == bookId) { - val nextTabId = currentTabs.last() - prefs.edit { - putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) - putString(KEY_ACTIVE_TAB, nextTabId) + val nextTabId = tabState.activeTabBookId ?: tabState.openTabIds.last() + persistTabState(tabState.openTabIds, nextTabId) + _internalState.update { + it.copy( + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = nextTabId + ) } - _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = nextTabId) } switchTab(nextTabId) } else { - prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) } - _internalState.update { it.copy(openTabIds = currentTabs) } + persistTabState(tabState.openTabIds, tabState.activeTabBookId) + _internalState.update { + it.copy( + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId + ) + } } } } @@ -870,7 +943,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun onSearchQueryChange(newQuery: String) { _internalState.update { if (it.isSearchActive) { - it.copy(searchQuery = newQuery) + it.withSharedLibraryAction(SharedLibraryAction.SearchChanged(newQuery)) } else { it } @@ -1061,7 +1134,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio init { Timber.d("ViewModel instance created.") - WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME) + WorkManager.getInstance(application).apply { + cancelUniqueWork(FolderSyncWorker.WORK_NAME) + pruneWork() + } val locatorConverter = LocatorConverter( bookCacheDao, @@ -1112,6 +1188,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio remoteConfigRepository.init() + viewModelScope.launch { + _internalState + .map { it.bannerMessage } + .distinctUntilChanged() + .collect { banner -> + scheduleBannerAutoDismiss(banner) + } + } + if (_internalState.value.syncedFolders.isNotEmpty()) { triggerFolderSyncWorker(metadataOnly = false, showFeedback = false) } @@ -1337,7 +1422,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio FileType.EPUB -> epubParser.createEpubBook( inputStream = inputStream, bookId = bookId, - originalBookNameHint = displayName + originalBookNameHint = displayName, + sourceFingerprint = epubSourceFingerprint(uri) ) FileType.MOBI -> mobiParser.createMobiBook( @@ -1627,10 +1713,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return@launch } + val tokenHash = PurchaseAccountObfuscator.purchaseTokenHash(purchase.purchaseToken) + Timber.i( + "Verifying purchase. productId=$productId tokenHash=$tokenHash orderId=${purchase.orderId} " + + "obfuscatedAccountId=${purchase.obfuscatedAccountId} uid=${_internalState.value.currentUser?.uid} " + + "silent=$isSilentMigrationCheck" + ) + val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken, productId) if (result.isSuccess) { Timber.i("Backend verification successful. Firestore will update the app.") + billingClientWrapper.clearAccountConflict() if (productId.startsWith("credits_")) { billingClientWrapper.consumePurchase(purchase.purchaseToken) @@ -1649,6 +1743,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.i("Migration/Refresh check: Purchase token is already claimed. Silently ignoring.") if (productId.startsWith("credits_")) { billingClientWrapper.consumePurchase(purchase.purchaseToken) + } else { + billingClientWrapper.markAccountConflict() } } else { val errorMessage = appContext.getString(R.string.error_purchase_verification) @@ -1827,36 +1923,36 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun togglePinForContextualItems(isHome: Boolean) { - val selectedIds = _internalState.value.contextualActionItems.map { it.bookId }.toSet() - if (selectedIds.isEmpty()) return + if (_internalState.value.contextualActionItems.isEmpty()) return + var pinsToPersist: Set = emptySet() + val projectedState = uiState.value _internalState.update { state -> - val currentPins = if (isHome) state.pinnedHomeBookIds else state.pinnedLibraryBookIds - val allPinned = selectedIds.all { it in currentPins } - - val newPins = if (allPinned) currentPins - selectedIds else currentPins + selectedIds - - prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, newPins) } - - if (isHome) { - state.copy(pinnedHomeBookIds = newPins, contextualActionItems = emptySet()) - } else { - state.copy(pinnedLibraryBookIds = newPins, contextualActionItems = emptySet()) - } + val updated = AndroidSharedStateBridge.togglePinsForSelectedBooks( + current = state, + projectedState = projectedState, + isHome = isHome + ) + pinsToPersist = if (isHome) updated.pinnedHomeBookIds else updated.pinnedLibraryBookIds + updated } + prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, pinsToPersist) } } fun updateLibraryFilters(filters: LibraryFilters) { - _internalState.update { it.copy(libraryFilters = filters) } + val sanitizedFilters = filters.copy( + fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES } + ) + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters.toSharedLibraryFilters())) } prefs.edit { - putStringSet(KEY_FILTER_FILE_TYPES, filters.fileTypes.map { it.name }.toSet()) - putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) - putString(KEY_FILTER_READ_STATUS, filters.readStatus.name) - putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) + putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet()) + putStringSet(KEY_FILTER_FOLDERS, sanitizedFilters.sourceFolders) + putString(KEY_FILTER_READ_STATUS, sanitizedFilters.readStatus.name) + putStringSet(KEY_FILTER_TAG_IDS, sanitizedFilters.tagIds) } - Timber.d("Library filters updated and persisted: $filters") + Timber.d("Library filters updated and persisted: $sanitizedFilters") } suspend fun sharePdf( @@ -1979,7 +2075,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val hasTextBoxes = textBoxFile.exists() val hasHighlights = highlightFile.exists() val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " + "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData" ) @@ -1997,7 +2093,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio try { val content = file.readText().trim() if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " + "file=${file.absolutePath}" ) @@ -2009,8 +2105,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } catch (e: Exception) { if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) - .e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}") + Timber.e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}") } Timber.e(e, "Failed to parse local $key file") } @@ -2027,7 +2122,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) bundleFile.writeText(canonicalBundle) if (hasRichText) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " + "bundleFile=${bundleFile.absolutePath}" ) @@ -2040,14 +2135,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (uploaded != null) { if (hasRichText) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + Timber .d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}") } Timber.tag("AnnotationSync") .d("Bundle upload SUCCESS. ID: ${uploaded.id}") } else { if (hasRichText) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + Timber .e("android.cloud.export.uploadFailed book=${book.bookId}") } Timber.tag("AnnotationSync") @@ -2299,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L) if (oldUri != null) { val name = getDisplayPathFromUri(appContext, oldUri) - val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet()) + val migrated = SyncedFolder(oldUri, name, oldTime, ANDROID_SYNCABLE_FILE_TYPES) folders.add(migrated) saveSyncedFoldersToPrefs(folders) @@ -2323,7 +2418,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } catch (_: Exception) {} } } else { - allowedFileTypes.addAll(FileType.entries) + allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES) } folders.add( @@ -2331,7 +2426,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio uriString = obj.getString("uri"), name = obj.getString("name"), lastScanTime = obj.optLong("lastScanTime", 0L), - allowedFileTypes = allowedFileTypes + allowedFileTypes = allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES } ) ) } @@ -2350,7 +2445,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio obj.put("name", folder.name) obj.put("lastScanTime", folder.lastScanTime) val typesArray = JSONArray() - folder.allowedFileTypes.forEach { typesArray.put(it.name) } + folder.allowedFileTypes + .filter { it in ANDROID_SYNCABLE_FILE_TYPES } + .forEach { typesArray.put(it.name) } obj.put("allowedFileTypes", typesArray) jsonArray.put(obj) } @@ -2378,7 +2475,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) val name = getDisplayPathFromUri(appContext, folderUri.toString()) - val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet()) + val newFolder = SyncedFolder(folderUri.toString(), name, 0L, ANDROID_SYNCABLE_FILE_TYPES) val newStats = currentFolders + newFolder saveSyncedFoldersToPrefs(newStats) @@ -2480,48 +2577,51 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) viewModelScope.launch { - workManager.getWorkInfoByIdFlow(request.id).collect { workInfo -> - if (workInfo != null) { - when (workInfo.state) { - WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { - if (showFeedback) { - val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning) - _internalState.update { - it.copy( - isLoading = false, - isRefreshing = true, - bannerMessage = BannerMessage(msg, isPersistent = true) - ) - } - } - } - - WorkInfo.State.SUCCEEDED -> { + workManager.getWorkInfoByIdFlow(request.id).filterNotNull().first { workInfo -> + when (workInfo.state) { + WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { + if (showFeedback) { + val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning) _internalState.update { it.copy( isLoading = false, - isRefreshing = false, - bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage, - lastFolderScanTime = System.currentTimeMillis(), - syncedFolders = loadSyncedFoldersFromPrefs() + isRefreshing = true, + bannerMessage = BannerMessage(msg, isPersistent = true) ) } } - - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - _internalState.update { - it.copy( - isLoading = false, - isRefreshing = false, - errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage, - bannerMessage = null - ) - } - } - - else -> Unit } + + WorkInfo.State.SUCCEEDED -> { + _internalState.update { + it.copy( + isLoading = false, + isRefreshing = false, + bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage, + lastFolderScanTime = System.currentTimeMillis(), + syncedFolders = loadSyncedFoldersFromPrefs() + ) + } + } + + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + _internalState.update { + it.copy( + isLoading = false, + isRefreshing = false, + errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage, + bannerMessage = null + ) + } + } + + else -> Unit } + + if (workInfo.state.isFinished) { + workManager.pruneWork() + } + workInfo.state.isFinished } } } @@ -2531,13 +2631,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val currentFolders = _internalState.value.syncedFolders.toMutableList() val index = currentFolders.indexOfFirst { it.uriString == folder.uriString } if (index != -1) { - val updatedFolder = folder.copy(allowedFileTypes = newFilters) + val sanitizedFilters = newFilters.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES } + val updatedFolder = folder.copy(allowedFileTypes = sanitizedFilters) currentFolders[index] = updatedFolder saveSyncedFoldersToPrefs(currentFolders) _internalState.update { it.copy(syncedFolders = currentFolders) } val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) - .filter { it.type !in newFilters } + .filter { it.type !in sanitizedFilters } if (filesToRemove.isNotEmpty()) { Timber.d("Removing ${filesToRemove.size} files that no longer match the filter for folder ${folder.name}") @@ -2836,7 +2937,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun launchPurchaseFlow(activity: android.app.Activity, productId: String = BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID) { Timber.d("Attempting to launch purchase flow for $productId. Pro state is: ${proUpgradeState.value}") - billingClientWrapper.launchPurchaseFlow(activity, productId) + val currentUser = uiState.value.currentUser + if (currentUser == null) { + _internalState.update { + it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.sign_in_to_purchase), isError = true)) + } + return + } + + billingClientWrapper.clearAccountConflict() + billingClientWrapper.launchPurchaseFlow( + activity = activity, + productId = productId, + obfuscatedAccountId = PurchaseAccountObfuscator.obfuscatedAccountId(currentUser.uid) + ) } fun clearBillingError() { @@ -2865,6 +2979,54 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun shouldDownloadRemoteBookContent(local: RecentFileItem, remote: RecentFileItem): Boolean { + return local.sourceFolderUri == null && + !local.isDeleted && + local.type == FileType.EPUB && + remote.type == FileType.EPUB && + !remote.isDeleted && + remote.fileContentModifiedTimestamp > 0L && + remote.fileContentModifiedTimestamp > local.fileContentModifiedTimestamp + } + + private fun shouldUploadLocalBookContent(local: RecentFileItem, remote: RecentFileItem?): Boolean { + return local.sourceFolderUri == null && + local.type == FileType.EPUB && + local.fileContentModifiedTimestamp > 0L && + local.fileContentModifiedTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L) + } + + private suspend fun downloadCloudBookFile(accessToken: String, remote: RecentFileItem): Boolean { + val fileExtension = remote.type.name.lowercase() + val fileName = "${remote.bookId}.$fileExtension" + val driveFileId = googleDriveRepository.getFiles(accessToken) + ?.files + .orEmpty() + .firstOrNull { it.name == fileName } + ?.id + ?: return false + + val destinationFile = bookImporter.createBookFile(fileName) + if (!googleDriveRepository.downloadFile(accessToken, driveFileId, destinationFile)) { + destinationFile.delete() + return false + } + + if (remote.fileContentModifiedTimestamp > 0L) { + destinationFile.setLastModified(remote.fileContentModifiedTimestamp) + } + cleanupBookDataLocally(remote.bookId) + addFileToRecent( + destinationFile.toUri(), + remote.type, + remote.bookId, + customDisplayName = remote.displayName, + isRecent = remote.isRecent, + sourceFolderUri = null + ) + return true + } + fun setFolderSyncEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) } _internalState.update { it.copy(isFolderSyncEnabled = enabled) } @@ -2939,6 +3101,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val local = localBooksMap[bookId] val remote = remoteBooksMap[bookId] + if (local?.sourceFolderUri != null) { + Timber.d("Skipping cloud book metadata merge for local folder book: ${local.displayName}") + return@forEach + } + if (local != null && remote != null) { Timber.tag("AnnotationSync").d( "Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}" @@ -2947,7 +3114,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when { local != null && remote == null -> { - uploadSingleBookMetadata(local) + if (local.isDeleted) { + uploadSingleBookMetadata(local) + } else { + uploadNewBookAndMetadata(local) + } } local == null && remote != null -> { @@ -2958,15 +3129,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } local != null && remote != null -> { + val remoteItem = remote.toRecentFileItem() + val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remoteItem) + val downloadedRemoteContent = if (shouldDownloadContent) { + downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { local.displayName })) + } else { + false + } + if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) { - uploadSingleBookMetadata(local) + if (shouldUploadLocalBookContent(local, remoteItem)) { + uploadNewBookAndMetadata(local) + } else { + uploadSingleBookMetadata(local) + } } else { val isMetadataNewer = remote.lastModifiedTimestamp > local.lastModifiedTimestamp if (isMetadataNewer) { - recentFilesRepository.addRecentFile( + val remoteForLocalDb = if (shouldDownloadContent && !downloadedRemoteContent) { + remote.toRecentFileItem().copy( + fileContentModifiedTimestamp = local.fileContentModifiedTimestamp + ) + } else { remote.toRecentFileItem() + } + recentFilesRepository.addRecentFile( + remoteForLocalDb ) } @@ -3080,6 +3270,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val downloadJobs = mutableListOf() finalMergedBooks.forEach { book -> + if (book.sourceFolderUri != null) return@forEach val fileExtension = book.type.name.lowercase() val fileName = "${book.bookId}.$fileExtension" if (book.isDeleted) { @@ -3088,7 +3279,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio googleDriveRepository.deleteDriveFile(accessToken, fileId) } recentFilesRepository.deleteFilePermanently(listOf(book.bookId)) - } else if (book.isAvailable && !remoteFiles.containsKey(fileName)) { + } else if ( + book.sourceFolderUri == null && + book.isAvailable && + !remoteFiles.containsKey(fileName) + ) { book.getUri()?.path?.let { path -> val file = File(path) if (file.exists()) { @@ -3139,7 +3334,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio try { val jsonString = tempDownloadFile.readText() - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}" ) @@ -3175,7 +3370,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val bundle = JSONObject( SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) ) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" ) @@ -3185,13 +3380,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val content = bundle.get(key).toString() file.writeText(content) if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" ) } } else { if (key == "text" && file.exists()) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}" ) } @@ -3255,6 +3450,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 0L } } + val fileContentModifiedTimestamp = withContext(Dispatchers.IO) { + try { + if (uri.scheme == "file") { + uri.path?.let { File(it).lastModified() } ?: 0L + } else { + DocumentFile.fromSingleUri(appContext, uri)?.lastModified() ?: 0L + } + } catch (e: Exception) { + Timber.e(e, "Failed to get file modified time for $uri") + 0L + } + } val existingItem = recentFilesRepository.getFileByBookId(bookId) val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( @@ -3356,7 +3563,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio seriesName = seriesName ?: finalBookMetadata.seriesName seriesIndex = seriesIndex ?: finalBookMetadata.seriesIndex description = description ?: finalBookMetadata.description - } else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + } else if (type in PDF_VIEWER_FILE_TYPES) { title = title ?: displayName if (type == FileType.PDF) { @@ -3389,6 +3596,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) } } + } else if (type == FileType.PPTX) { + if (coverPath == null) { + val pptxCoverGenerator = PptxCoverGenerator(appContext) + val coverBitmap = pptxCoverGenerator.generateCover(uri) + if (coverBitmap != null) { + coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) + } + } } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) { if (coverPath == null) { var cacheFile: File? = null @@ -3452,6 +3667,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isRecent = isRecent, sourceFolderUri = sourceFolderUri, fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, seriesName = seriesName, seriesIndex = seriesIndex, description = description @@ -3465,21 +3681,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun setRecentFilesLimit(limit: Int) { - _internalState.update { it.copy(recentFilesLimit = limit) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.RecentLimitChanged(limit)) } prefs.edit { putInt(KEY_RECENT_FILES_LIMIT, limit) } } fun setSortOrder(sortOrder: SortOrder) { - _internalState.update { it.copy(sortOrder = sortOrder) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder.toSharedSortOrder())) } prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) } } fun bannerMessageShown() { _internalState.update { it.copy(bannerMessage = null) } + scheduleBannerAutoDismiss(null) } - fun showBanner(message: String, isError: Boolean = false) { - _internalState.update { it.copy(bannerMessage = BannerMessage(message, isError)) } + fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { + val banner = BannerMessage(message, isError, isPersistent) + _internalState.update { it.copy(bannerMessage = banner) } + scheduleBannerAutoDismiss(banner) + } + + private fun scheduleBannerAutoDismiss(banner: BannerMessage?) { + if (_internalState.value.bannerMessage != banner) return + bannerDismissJob?.cancel() + bannerDismissJob = null + val generation = ++bannerDismissGeneration + if (banner == null || banner.isPersistent) return + + bannerDismissJob = viewModelScope.launch { + delay(BANNER_AUTO_DISMISS_MILLIS) + _internalState.update { state -> + if (generation == bannerDismissGeneration && state.bannerMessage == banner) { + state.copy(bannerMessage = null) + } else { + state + } + } + } } fun errorMessageShown() { @@ -3584,6 +3822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } var importedCount = 0 + var duplicateCount = 0 + var unsupportedCount = 0 + var failedCount = 0 withContext(Dispatchers.IO) { for (externalUri in uris) { @@ -3607,22 +3848,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio appContext.contentResolver.openInputStream(externalUri) } if (hash != null && recentFilesRepository.getFileByBookId(hash) != null) { - importedCount++ + duplicateCount++ + } else if (getFileTypeFromUri(externalUri, appContext) == null) { + unsupportedCount++ + } else { + failedCount++ } } } } _internalState.update { + val feedback = SharedImportPlanner.feedbackForCounts( + counts = SharedImportOutcomeCounts( + addedCount = importedCount, + duplicateCount = duplicateCount, + unsupportedCount = unsupportedCount, + failedCount = failedCount + ), + importedMessage = "Imported $importedCount books. You can find them in the Library tab.", + duplicateMessage = "Those files are already in the library.", + unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type), + failedMessage = appContext.getString(R.string.error_import_file_failed) + ) it.copy( bannerMessage = BannerMessage( - message = "Imported $importedCount books. You can find them in the Library tab.", + message = feedback.message, + isError = feedback.isError, isPersistent = false ) ) } - Timber.tag("BulkImport").i("Bulk import complete. $importedCount files processed.") + Timber.tag("BulkImport").i("Bulk import complete. $importedCount new files, $duplicateCount duplicates, $unsupportedCount unsupported, $failedCount failed.") } } @@ -3675,8 +3933,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return@launch } } + val messageRes = if (getFileTypeFromUri(externalUri, appContext) == null) { + R.string.error_unsupported_file_type + } else { + R.string.error_import_file_failed + } _internalState.update { - it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) + it.copy(isLoading = false, errorMessage = appContext.getString(messageRes)) } } } catch (e: SecurityException) { @@ -3711,11 +3974,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - val reflowWorkInfo: Flow = - WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) - .map { list -> - list.find { !it.state.isFinished } ?: list.firstOrNull() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + private val _reflowWorkInfo = MutableStateFlow(null) + val reflowWorkInfo: StateFlow = _reflowWorkInfo.asStateFlow() fun switchToFileSeamlessly(item: RecentFileItem, syncPosition: Int) { viewModelScope.launch { @@ -3741,7 +4001,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val type = item.type val bookId = item.bookId - if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + if (type in PDF_VIEWER_FILE_TYPES) { persistReaderSession(bookId, type) _internalState.update { it.copy( @@ -3771,7 +4031,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri)) stateUpdateDeferred.complete(true) - } else { + } else if (type in EPUB_READER_FILE_TYPES) { persistReaderSession(bookId, type) try { val epubBook = restoreEpubReaderBook(type, bookId, item.displayName, uri) @@ -3818,6 +4078,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } stateUpdateDeferred.complete(false) } + } else { + _internalState.update { + it.copy( + isLoading = false, + errorMessage = appContext.getString(R.string.error_unsupported_file_type), + selectedFileType = null + ) + } + stateUpdateDeferred.complete(false) } } } @@ -3857,11 +4126,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio "reflow_$pdfBookId", ExistingWorkPolicy.KEEP, request ) + val finalWorkInfo = CompletableDeferred() + + launch { + workManager.getWorkInfoByIdFlow(request.id).filterNotNull().first { workInfo -> + _reflowWorkInfo.value = workInfo + if (workInfo.state.isFinished) { + finalWorkInfo.complete(workInfo) + workManager.pruneWork() + } + workInfo.state.isFinished + } + } + if (autoOpenPage != null) { launch { importMutex.withLock { - val finalInfo = workManager.getWorkInfoByIdFlow(request.id).filterNotNull() - .first { it.state.isFinished } + val finalInfo = finalWorkInfo.await() if (finalInfo.state == WorkInfo.State.SUCCEEDED) { var retries = 0 @@ -3900,6 +4181,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun epubSourceFingerprint(uri: Uri): String? { + return try { + if (uri.scheme == "file") { + val path = uri.path ?: return null + val file = File(path) + if (!file.isFile) return null + "${file.length()}:${file.lastModified()}" + } else { + val document = DocumentFile.fromSingleUri(appContext, uri) ?: return null + val length = document.length() + val modified = document.lastModified() + if (length <= 0L && modified <= 0L) null else "$length:$modified" + } + } catch (e: Exception) { + Timber.w(e, "Failed to compute EPUB source fingerprint for $uri") + null + } + } + private fun openBook( uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null ) { @@ -3908,22 +4208,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileOpenPerf") .d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") - if (_internalState.value.isTabsEnabled && type == FileType.PDF) { - val currentTabs = _internalState.value.openTabIds.toMutableList() - if (!currentTabs.contains(bookId)) { - if (currentTabs.size >= 20) { + val currentTabState = _internalState.value + if (currentTabState.isTabsEnabled && type == FileType.PDF) { + if (bookId !in currentTabState.openTabIds) { + if (currentTabState.openTabIds.size >= 20) { viewModelScope.launch(Dispatchers.Main) { showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) } return } - currentTabs.add(bookId) } - prefs.edit { - putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) - putString(KEY_ACTIVE_TAB, bookId) + val tabState = AndroidSharedStateBridge.openBookTab( + current = currentTabState, + projectedState = uiState.value, + bookId = bookId + ) + persistTabState(tabState.openTabIds, tabState.activeTabBookId) + _internalState.update { + it.copy( + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId + ) } - _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } } if (uri.scheme != "opds-pse") { @@ -3968,7 +4275,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + if (type in PDF_VIEWER_FILE_TYPES) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) @@ -4056,6 +4363,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } } + } else { + _internalState.update { + it.copy( + selectedPdfUri = null, + selectedEpubUri = null, + selectedEpubBook = null, + selectedFileType = null, + selectedBookId = null, + isLoading = false, + errorMessage = appContext.getString(R.string.error_unsupported_file_type) + ) + } } } } @@ -4143,6 +4462,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") viewModelScope.launch { + if (type !in EPUB_READER_FILE_TYPES) { + _internalState.update { + it.copy( + errorMessage = appContext.getString(R.string.error_unsupported_file_type), + isLoading = false + ) + } + return@launch + } if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } @@ -4212,40 +4540,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") - return when (mimeType) { - "application/vnd.oasis.opendocument.text" -> FileType.ODT - "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT - "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 - } - "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> { - if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null - } - "application/x-cb7", "application/x-7z-compressed" -> { - if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null - } - "application/pdf" -> FileType.PDF - "application/epub+zip" -> FileType.EPUB - "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 - "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI - "text/markdown", "text/x-markdown" -> FileType.MD - "text/html", "application/xhtml+xml" -> FileType.HTML - - "text/csv", "text/comma-separated-values", "text/tab-separated-values", - "application/json", "application/xml", "text/xml", - "text/x-java-source", "text/x-python", "text/x-kotlin", - "text/javascript", "application/javascript", - "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML - - "text/plain" -> { - resolveFileTypeFromName(fileName) ?: FileType.TXT - } - - else -> { - resolveFileTypeFromName(fileName) - } - } + return resolveFileTypeFromMetadata(fileName, mimeType) } private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { @@ -4318,7 +4613,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio epubParser.createEpubBook( inputStream = inputStream, bookId = bookId, - originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub" + originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub", + sourceFingerprint = epubSourceFingerprint(uri) ) } } @@ -4499,13 +4795,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val currentSelection = _internalState.value.contextualActionItems if (currentSelection.isNotEmpty()) { Timber.d("Toggling selection for: ${item.displayName}") - val newSelection = if (currentSelection.any { it.bookId == item.bookId }) { - currentSelection.filterNot { it.bookId == item.bookId }.toSet() - } else { - currentSelection + item - } - _internalState.update { it.copy(contextualActionItems = newSelection) } - Timber.d("New selection size: ${newSelection.size}") + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.BookSelectionToggled(item.bookId)) } + Timber.d("New selection size: ${_internalState.value.contextualActionItems.size}") } else { if (item.sourceFolderUri != null && item.uriString != null) { viewModelScope.launch { @@ -4555,6 +4846,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun uploadNewBookAndMetadata(book: RecentFileItem) { if (!uiState.value.isSyncEnabled) return + if (book.uriString?.startsWith("opds-pse") == true) { + Timber.d("Skipping book content sync for OPDS stream book: ${book.displayName}") + return + } + + if (book.sourceFolderUri != null) { + Timber.d("Skipping book content sync for local folder book: ${book.displayName}") + return + } + + if (book.isManualOnlyReaderFile()) { + Timber.d("Skipping book content sync for manual-only reader file: ${book.displayName}") + return + } + viewModelScope.launch { _internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) } try { @@ -4598,37 +4904,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio "Long press on: ${item.displayName}. Current selection size: ${currentSelection.size}" ) if (currentSelection.none { it.bookId == item.bookId }) { - _internalState.update { it.copy(contextualActionItems = currentSelection + item) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.BookSelectionToggled(item.bookId)) } } Timber.d("New selection size: ${_internalState.value.contextualActionItems.size}") } fun selectAllRecentFiles() { - val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet() + val projectedState = uiState.value + val currentVisible = projectedState.recentFiles.filter { it.isRecent } _internalState.update { state -> - if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { - state.copy(contextualActionItems = emptySet()) - } else { - state.copy(contextualActionItems = currentVisible) - } + AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks( + current = state, + projectedState = projectedState, + visibleBooks = currentVisible + ) } } fun selectAllLibraryFiles() { - val currentVisible = uiState.value.allRecentFiles.toSet() + val projectedState = uiState.value + val currentVisible = projectedState.allRecentFiles _internalState.update { state -> - if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { - state.copy(contextualActionItems = emptySet()) - } else { - state.copy(contextualActionItems = currentVisible) - } + AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks( + current = state, + projectedState = projectedState, + visibleBooks = currentVisible + ) } } fun clearContextualAction() { Timber.d("Clearing contextual action mode.") if (_internalState.value.contextualActionItems.isNotEmpty()) { - _internalState.update { it.copy(contextualActionItems = emptySet()) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SelectionCleared) } } } @@ -4784,30 +5092,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun toggleShelfSelection(shelf: Shelf) { if (shelf.type != ShelfType.MANUAL) return - _internalState.update { state -> - val currentSelection = state.contextualActionShelfIds - val newSelection = if (shelf.id in currentSelection) { - currentSelection - shelf.id - } else { - currentSelection + shelf.id - } - state.copy(contextualActionShelfIds = newSelection) - } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionToggled(shelf.id)) } } fun onShelfLongPress(shelf: Shelf) { if (shelf.type != ShelfType.MANUAL || shelf.id == "unshelved") return val currentSelection = _internalState.value.contextualActionShelfIds if (shelf.id !in currentSelection) { - _internalState.update { - it.copy(contextualActionShelfIds = currentSelection + shelf.id) - } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionToggled(shelf.id)) } } } fun clearShelfContextualAction() { if (_internalState.value.contextualActionShelfIds.isNotEmpty()) { - _internalState.update { it.copy(contextualActionShelfIds = emptySet()) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionCleared) } } } @@ -5087,7 +5385,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio speechBubbleDetector?.close() speechBubbleDetector = null speechBubbleCache.clear() + speechBubbleDetectionJobs.values.forEach { it.cancel() } speechBubbleDetectionJobs.clear() + mlDispatcher.close() ttsController.release() @@ -5206,20 +5506,108 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (updatedItem.sourceFolderUri != null) { launch(Dispatchers.IO) { - recentFilesRepository.syncLocalMetadataToFolder(bookId) + recentFilesRepository.syncLocalMetadataToFolder(bookId, force = true) } } } } } + fun updateBookMetadata(bookId: String, metadata: BookMetadataEdit) { + viewModelScope.launch { + val currentItem = recentFilesRepository.getFileByBookId(bookId) ?: return@launch + if (currentItem.type != FileType.EPUB) { + showBanner("Only EPUB files support embedded metadata editing right now.", isError = true) + return@launch + } + + val editResult = epubMetadataFileEditor.writeMetadata(currentItem, metadata) + editResult.onFailure { error -> + Timber.e(error, "Failed to update EPUB metadata for $bookId") + showBanner("Could not update EPUB metadata.", isError = true) + }.onSuccess { result -> + cleanupBookDataLocally(bookId) + val savedMetadata = BookMetadataEdit( + title = result.metadata.title ?: metadata.title, + author = result.metadata.author, + seriesName = result.metadata.seriesName, + seriesIndex = result.metadata.seriesIndex, + description = result.metadata.description + ) + recentFilesRepository.updateUserEditableMetadata( + bookId = bookId, + metadata = savedMetadata, + fileSize = result.fileSize, + fileContentModifiedTimestamp = result.fileContentModifiedTimestamp + ) + val updatedItem = recentFilesRepository.getFileByBookId(bookId) + if (updatedItem != null && uiState.value.isSyncEnabled && updatedItem.sourceFolderUri == null) { + uploadNewBookAndMetadata(updatedItem) + } + showBanner("EPUB metadata updated.") + } + } + } + + fun restoreOriginalBookMetadata(bookId: String) { + viewModelScope.launch { + val currentItem = recentFilesRepository.getFileByBookId(bookId) ?: return@launch + if (currentItem.type != FileType.EPUB) { + showBanner("Only EPUB files support embedded metadata restore right now.", isError = true) + return@launch + } + val originalTitle = currentItem.originalTitle ?: currentItem.title + if (originalTitle.isNullOrBlank() && + currentItem.originalAuthor.isNullOrBlank() && + currentItem.originalSeriesName.isNullOrBlank() && + currentItem.originalDescription.isNullOrBlank() && + currentItem.originalSeriesIndex == null + ) { + showBanner("No original EPUB metadata is available.", isError = true) + return@launch + } + + val metadata = BookMetadataEdit( + title = originalTitle ?: currentItem.displayName.substringBeforeLast('.', currentItem.displayName), + author = currentItem.originalAuthor, + seriesName = currentItem.originalSeriesName, + seriesIndex = currentItem.originalSeriesIndex, + description = currentItem.originalDescription + ) + val editResult = epubMetadataFileEditor.writeMetadata(currentItem, metadata) + editResult.onFailure { error -> + Timber.e(error, "Failed to restore EPUB metadata for $bookId") + showBanner("Could not restore EPUB metadata.", isError = true) + }.onSuccess { result -> + cleanupBookDataLocally(bookId) + recentFilesRepository.restoreOriginalMetadata( + bookId = bookId, + fileSize = result.fileSize, + fileContentModifiedTimestamp = result.fileContentModifiedTimestamp + ) + val restoredItem = recentFilesRepository.getFileByBookId(bookId) + if (restoredItem != null && uiState.value.isSyncEnabled && restoredItem.sourceFolderUri == null) { + uploadNewBookAndMetadata(restoredItem) + } + showBanner("Original EPUB metadata restored.") + } + } + } + fun closeAllTabs() { Timber.tag("PdfTabSync").i("ViewModel: closeAllTabs called") - prefs.edit { - remove(KEY_OPEN_TAB_IDS) - remove(KEY_ACTIVE_TAB) + val tabState = AndroidSharedStateBridge.closeAllTabs( + current = _internalState.value, + projectedState = uiState.value + ) + persistTabState(tabState.openTabIds, tabState.activeTabBookId) + _internalState.update { + it.copy( + isTabsEnabled = tabState.isTabsEnabled, + openTabIds = tabState.openTabIds, + activeTabBookId = tabState.activeTabBookId + ) } - _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } clearSelectedFile() } @@ -5255,27 +5643,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun setAppThemeMode(mode: AppThemeMode) { - _internalState.update { it.copy(appThemeMode = mode) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode.toSharedAppThemeMode())) } prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) } } fun setAppContrastOption(option: AppContrastOption) { - _internalState.update { it.copy(appContrastOption = option) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option.toSharedAppContrastOption())) } prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } } fun setAppTextDimFactorLight(factor: Float) { - _internalState.update { it.copy(appTextDimFactorLight = factor) } - prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppTextDimFactorLightChanged(factor)) } + prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, _internalState.value.appTextDimFactorLight) } } fun setAppTextDimFactorDark(factor: Float) { - _internalState.update { it.copy(appTextDimFactorDark = factor) } - prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppTextDimFactorDarkChanged(factor)) } + prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, _internalState.value.appTextDimFactorDark) } } fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) { - _internalState.update { it.copy(appSeedColor = color) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppSeedColorChanged(color)) } prefs.edit { if (color == null) { remove(KEY_APP_SEED_COLOR) @@ -5286,18 +5674,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun addCustomAppTheme(theme: CustomAppTheme) { - val current = _internalState.value.customAppThemes.filter { it.id != theme.id } + theme - _internalState.update { it.copy(customAppThemes = current) } + _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme.toSharedCustomAppTheme())) } + val current = _internalState.value.customAppThemes saveCustomAppThemes(current) - setAppSeedColor(theme.seedColor) + prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) } } fun deleteCustomAppTheme(themeId: String) { - val current = _internalState.value.customAppThemes.filter { it.id != themeId } - _internalState.update { it.copy(customAppThemes = current) } + _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeDeleted(themeId)) } + val current = _internalState.value.customAppThemes saveCustomAppThemes(current) - if (_internalState.value.appSeedColor != null && !current.any { it.seedColor == _internalState.value.appSeedColor }) { - setAppSeedColor(null) + prefs.edit { + val seed = _internalState.value.appSeedColor + if (seed == null) { + remove(KEY_APP_SEED_COLOR) + } else { + putInt(KEY_APP_SEED_COLOR, seed.toArgb()) + } } } @@ -5462,7 +5855,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (cachedInsideLock != null) { detectionJob = CompletableDeferred(cachedInsideLock) } else { - detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async { + detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async(mlDispatcher) { val detected = runSpeechBubbleDetection(bitmap, context) val normalized = normalizeSpeechBubbles(detected, bitmap.width, bitmap.height) speechBubbleCache[key] = normalized @@ -5529,6 +5922,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio "application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed", "application/x-cb7", "application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml", "text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json", "application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin", diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index c30ca88..fe5db42 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -6,7 +6,10 @@ import android.provider.OpenableColumns import android.util.Xml import androidx.core.net.toUri import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters +import androidx.work.WorkManager import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository import io.legere.pdfiumandroid.PdfiumCore @@ -28,7 +31,17 @@ class MetadataExtractionWorker( const val WORK_NAME = "MetadataExtractionWorker" const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri" private const val METADATA_DB_BATCH_SIZE = 100 + private const val METADATA_WORKER_BOOK_BATCH_SIZE = 300 private const val METADATA_PROGRESS_LOG_EVERY = 250 + private val TEXT_METADATA_TYPES = setOf( + FileType.PDF, + FileType.EPUB, + FileType.MOBI, + FileType.FB2, + FileType.ODT, + FileType.FODT, + FileType.DOCX + ) } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { @@ -45,20 +58,25 @@ class MetadataExtractionWorker( } try { - val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri) + val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata( + sourceFolderUri = sourceFolderUri, + limit = METADATA_WORKER_BOOK_BATCH_SIZE + ) if (filesToProcess.isEmpty()) { - ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}") + ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}") return@withContext Result.success() } ReaderPerfLog.i( - "MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}" + "MetadataWorker start mode=metadata books=${filesToProcess.size} " + + "batchLimit=$METADATA_WORKER_BOOK_BATCH_SIZE folder=${sourceFolderUri ?: "ALL"}" ) val pendingUpdates = mutableListOf() var processed = 0 var updated = 0 + var coversUpdated = 0 var failed = 0 suspend fun flushUpdates() { @@ -76,36 +94,80 @@ class MetadataExtractionWorker( if (item.sourceFolderUri == null) return@forEach + var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed + var needsEmbeddedCover = false + try { val uri = item.uriString?.toUri() ?: return@forEach val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri) + val existingCoverIsAvailable = item.coverImagePath?.let { File(it).isFile } == true + needsEmbeddedCover = EmbeddedEbookMetadataExtractor.canExtractEmbeddedCover(item.type) && + !item.folderCoverMetadataParsed && + !existingCoverIsAvailable + val metadata = when (item.type) { - FileType.EPUB -> parseEpubTextMetadata(uri) + FileType.EPUB, + FileType.MOBI, + FileType.FB2 -> { + if (needsTextMetadata || needsEmbeddedCover) { + EmbeddedEbookMetadataExtractor.extract( + type = item.type, + displayName = item.displayName, + openStream = { appContext.contentResolver.openInputStream(uri) }, + extractCover = needsEmbeddedCover + ).toTextMetadata() + } else { + TextMetadata() + } + } FileType.PDF -> parsePdfTextMetadata(uri) FileType.ODT -> parseZipTextMetadata(uri, "meta.xml") FileType.FODT -> parseFlatXmlTextMetadata(uri) FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml") + FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml") else -> TextMetadata() } val title = sanitizeTitle(metadata.title) val author = sanitizeAuthor(metadata.author) + val description = metadata.description?.trim()?.takeIf { it.isNotBlank() } + val seriesName = metadata.seriesName?.trim()?.takeIf { it.isNotBlank() } + val seriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 } val sizeChanged = fileSize > 0L && fileSize != item.fileSize val titleChanged = title != null && title != item.title val authorChanged = author != null && author != item.author + val descriptionChanged = description != null && description != item.description + val seriesChanged = seriesName != null && seriesName != item.seriesName + val seriesIndexChanged = seriesIndex != null && seriesIndex != item.seriesIndex + val coverPath = if (needsEmbeddedCover) { + metadata.cover?.let { cover -> + recentFilesRepository.saveEmbeddedCoverToCache(cover.bytes, uri, cover.extension) + } + } else { + null + } + val coverChanged = coverPath != null && coverPath != item.coverImagePath + val coverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover + val textMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata - if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) { + if (needsTextMetadata || needsEmbeddedCover || sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) { pendingUpdates.add( item.copy( + coverImagePath = coverPath ?: item.coverImagePath, title = title ?: item.title ?: item.displayName, author = author ?: item.author, + description = description ?: item.description, + seriesName = seriesName ?: item.seriesName, + seriesIndex = seriesIndex ?: item.seriesIndex, fileSize = if (fileSize > 0L) fileSize else item.fileSize, - folderTextMetadataParsed = true + folderTextMetadataParsed = textMetadataParsed, + folderCoverMetadataParsed = coverMetadataParsed ) ) - if (sizeChanged || titleChanged || authorChanged) { + if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) { updated++ } + if (coverChanged) coversUpdated++ if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) { flushUpdates() } @@ -114,29 +176,64 @@ class MetadataExtractionWorker( processed++ if (processed % METADATA_PROGRESS_LOG_EVERY == 0) { ReaderPerfLog.d( - "MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed" + "MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed" ) } } catch (e: Exception) { failed++ - Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}") + Timber.tag("MetadataWorker").e(e, "Failed metadata extraction for ${item.displayName}") + if (needsTextMetadata || needsEmbeddedCover) { + pendingUpdates.add( + item.copy( + folderTextMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata, + folderCoverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover + ) + ) + if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) { + flushUpdates() + } + } } } flushUpdates() + val nextBatchEnqueued = !isStopped && + filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE && + recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri) + if (nextBatchEnqueued) { + enqueueNextBatch(sourceFolderUri) + } + ReaderPerfLog.i( - "MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " + - "elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}" + "MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " + + "nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}" ) return@withContext Result.success() } catch (e: Exception) { - Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed") + Timber.tag("MetadataWorker").e(e, "Metadata extraction failed") return@withContext Result.failure() } } + private fun enqueueNextBatch(sourceFolderUri: String?) { + val data = androidx.work.Data.Builder().apply { + if (!sourceFolderUri.isNullOrBlank()) { + putString(KEY_SOURCE_FOLDER_URI, sourceFolderUri) + } + }.build() + val request = OneTimeWorkRequestBuilder() + .setInputData(data) + .build() + WorkManager.getInstance(appContext).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request + ) + ReaderPerfLog.d("MetadataWorker enqueued next metadata batch folder=${sourceFolderUri ?: "ALL"}") + } + private fun queryFileSize(uri: android.net.Uri): Long { return try { if (uri.scheme == "file") { @@ -157,30 +254,6 @@ class MetadataExtractionWorker( } } - private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata { - val opfEntries = linkedMapOf() - var containerXml: String? = null - - appContext.contentResolver.openInputStream(uri)?.use { input -> - ZipInputStream(input.buffered()).use { zip -> - while (true) { - val entry = zip.nextEntry ?: break - if (entry.isDirectory) continue - val name = entry.name - when { - name == "META-INF/container.xml" -> containerXml = zip.readTextEntry() - name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry() - } - zip.closeEntry() - } - } - } - - val opfPath = containerXml?.let { parseEpubRootfilePath(it) } - val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull() - return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata() - } - private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata { appContext.contentResolver.openInputStream(uri)?.use { input -> ZipInputStream(input.buffered()).use { zip -> @@ -222,21 +295,6 @@ class MetadataExtractionWorker( } } - private fun parseEpubRootfilePath(containerXml: String): String? { - val parser = Xml.newPullParser() - parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) - parser.setInput(containerXml.reader()) - - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) { - return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() } - } - event = parser.next() - } - return null - } - private fun parseXmlTextMetadata(xml: String): TextMetadata { val parser = Xml.newPullParser() parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) @@ -286,8 +344,23 @@ class MetadataExtractionWorker( ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } } + private fun EmbeddedEbookMetadata.toTextMetadata(): TextMetadata { + return TextMetadata( + title = title, + author = author, + description = description, + seriesName = seriesName, + seriesIndex = seriesIndex, + cover = cover + ) + } + private data class TextMetadata( val title: String? = null, - val author: String? = null + val author: String? = null, + val description: String? = null, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val cover: EmbeddedEbookCover? = null ) } diff --git a/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt b/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt deleted file mode 100644 index 116870e..0000000 --- a/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.aryan.reader - -import com.aryan.reader.data.RecentFileItem - -data class HomeScreenModel( - val recentFiles: List, - val openTabs: List, - val selectedItems: Set, - val isContextualModeActive: Boolean, - val deviceLimitState: DeviceLimitReachedState, - val isEmpty: Boolean, - val isLibraryEmpty: Boolean -) - -fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel { - val homeRecentFiles = recentFiles - return HomeScreenModel( - recentFiles = homeRecentFiles, - openTabs = openTabs, - selectedItems = contextualActionItems, - isContextualModeActive = contextualActionItems.isNotEmpty(), - deviceLimitState = deviceLimitState, - isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()), - isLibraryEmpty = recentFiles.isEmpty() - ) -} - -data class LibraryScreenModel( - val selectedItems: Set, - val isContextualModeActive: Boolean, - val selectedShelves: Set, - val isShelfContextualModeActive: Boolean, - val sortOrder: SortOrder, - val shelves: List, - val rawLibraryFiles: List, - val containsFolderItemsInSelection: Boolean, - val isSearchActive: Boolean, - val searchQuery: String -) - -fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel { - return LibraryScreenModel( - selectedItems = contextualActionItems, - isContextualModeActive = contextualActionItems.isNotEmpty(), - selectedShelves = contextualActionShelfIds, - isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(), - sortOrder = sortOrder, - shelves = shelves, - rawLibraryFiles = rawLibraryFiles, - containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null }, - isSearchActive = isSearchActive, - searchQuery = searchQuery - ) -} diff --git a/app/src/main/java/com/aryan/reader/ProScreen.kt b/app/src/main/java/com/aryan/reader/ProScreen.kt index c52c3ce..c92ac8b 100644 --- a/app/src/main/java/com/aryan/reader/ProScreen.kt +++ b/app/src/main/java/com/aryan/reader/ProScreen.kt @@ -274,7 +274,7 @@ private fun ProTierCard( ) { val productDetails = proUpgradeState.productDetails val billingClientReady = proUpgradeState.billingClientReady - val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase + val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict var originalFormattedPrice by remember { mutableStateOf("$9.99") } @@ -467,23 +467,6 @@ private fun ProTierCard( Text(stringResource(R.string.verifying_purchase)) } } - localPurchaseExistsForOtherAccount -> { - OutlinedButton( - onClick = onShowExistingPurchaseDialog, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.medium - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.info), - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - AutoSizeText(stringResource(R.string.existing_purchase_found)) - } - } productDetails != null -> { Button( onClick = { @@ -538,6 +521,17 @@ private fun ProTierCard( textAlign = TextAlign.Center ) } + localPurchaseExistsForOtherAccount -> { + TextButton(onClick = onShowExistingPurchaseDialog) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = stringResource(R.string.info), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + AutoSizeText(stringResource(R.string.existing_purchase_found)) + } + } else -> { LegalText(prefixText = stringResource(R.string.legal_by_purchasing)) } diff --git a/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt b/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt new file mode 100644 index 0000000..9604ae6 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt @@ -0,0 +1,22 @@ +package com.aryan.reader + +import java.security.MessageDigest +import java.util.Base64 + +object PurchaseAccountObfuscator { + fun obfuscatedAccountId(uid: String): String { + return "firebase_${sha256Base64Url(uid)}" + } + + fun purchaseTokenHash(purchaseToken: String): String { + return "sha256_${sha256Base64Url(purchaseToken)}" + } + + private fun sha256Base64Url(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(digest) + } +} diff --git a/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt b/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt new file mode 100644 index 0000000..348153b --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt @@ -0,0 +1,28 @@ +package com.aryan.reader + +import android.content.Context +import androidx.core.content.edit + +private const val READER_PAGINATION_PREFS_NAME = "reader_prefs" +private const val PDF_RIGHT_TO_LEFT_PAGINATION_KEY = "pdf_right_to_left_pagination_enabled" +private const val EPUB_RIGHT_TO_LEFT_PAGINATION_KEY = "epub_right_to_left_pagination_enabled" + +fun savePdfRightToLeftPagination(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) } +} + +fun loadPdfRightToLeftPagination(context: Context): Boolean { + val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, false) +} + +fun saveEpubRightToLeftPagination(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) } +} + +fun loadEpubRightToLeftPagination(context: Context): Boolean { + val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, false) +} diff --git a/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt b/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt new file mode 100644 index 0000000..ccb4f05 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt @@ -0,0 +1,183 @@ +package com.aryan.reader + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.edit + +private const val READER_SCREEN_ORIENTATION_PREFS_NAME = "epub_reader_settings" +private const val READER_SCREEN_ORIENTATION_KEY = "reader_screen_orientation_mode" + +enum class ReaderScreenOrientationMode( + val id: Int, + val title: String +) { + FOLLOW_SYSTEM(0, "Follow system"), + PORTRAIT(1, "Portrait"), + LANDSCAPE(2, "Landscape") +} + +fun saveReaderScreenOrientationMode(context: Context, mode: ReaderScreenOrientationMode) { + val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putInt(READER_SCREEN_ORIENTATION_KEY, mode.id) } +} + +fun loadReaderScreenOrientationMode(context: Context): ReaderScreenOrientationMode { + val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE) + val id = prefs.getInt(READER_SCREEN_ORIENTATION_KEY, ReaderScreenOrientationMode.FOLLOW_SYSTEM.id) + return ReaderScreenOrientationMode.entries.find { it.id == id } ?: ReaderScreenOrientationMode.FOLLOW_SYSTEM +} + +fun ReaderScreenOrientationMode.toRequestedOrientation(): Int { + return when (this) { + ReaderScreenOrientationMode.FOLLOW_SYSTEM -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + ReaderScreenOrientationMode.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + ReaderScreenOrientationMode.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } +} + +@Composable +fun ReaderScreenOrientationEffect(mode: ReaderScreenOrientationMode) { + val context = LocalContext.current + val activity: Activity? = remember(context) { context.findReaderOrientationActivity() } + + DisposableEffect(activity, mode) { + if (activity != null) { + val originalOrientation = activity.requestedOrientation + activity.requestedOrientation = mode.toRequestedOrientation() + + onDispose { + activity.requestedOrientation = originalOrientation + } + } else { + onDispose {} + } + } +} + +@Composable +fun ReaderScreenOrientationPicker( + selectedMode: ReaderScreenOrientationMode, + onModeSelected: (ReaderScreenOrientationMode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ReaderScreenOrientationMode.entries.forEach { mode -> + val selected = mode == selectedMode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onModeSelected(mode) } + .padding(vertical = 10.dp, horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = mode.title, + style = MaterialTheme.typography.labelSmall, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReaderScreenOrientationSheet( + selectedMode: ReaderScreenOrientationMode, + onModeSelected: (ReaderScreenOrientationMode) -> 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) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.visual_options_screen_orientation), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.visual_options_screen_orientation_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + ReaderScreenOrientationPicker( + selectedMode = selectedMode, + onModeSelected = onModeSelected + ) + } + } +} + +private tailrec fun Context.findReaderOrientationActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findReaderOrientationActivity() + else -> null + } +} diff --git a/app/src/main/java/com/aryan/reader/SettingsScreen.kt b/app/src/main/java/com/aryan/reader/SettingsScreen.kt new file mode 100644 index 0000000..991530a --- /dev/null +++ b/app/src/main/java/com/aryan/reader/SettingsScreen.kt @@ -0,0 +1,583 @@ +package com.aryan.reader + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import android.content.Context +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import androidx.navigation.NavHostController +import com.aryan.reader.data.CustomFontEntity +import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings +import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode +import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition +import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont +import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign +import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode +import com.aryan.reader.epubreader.loadFormatSettings +import com.aryan.reader.epubreader.loadPageInfoMode +import com.aryan.reader.epubreader.loadPageInfoPosition +import com.aryan.reader.epubreader.loadPullToTurn +import com.aryan.reader.epubreader.loadPullToTurnMultiplier +import com.aryan.reader.epubreader.loadSystemUiMode +import com.aryan.reader.epubreader.savePageInfoMode +import com.aryan.reader.epubreader.savePageInfoPosition +import com.aryan.reader.epubreader.savePullToTurn +import com.aryan.reader.epubreader.savePullToTurnMultiplier +import com.aryan.reader.epubreader.saveReaderSettings +import com.aryan.reader.epubreader.saveSystemUiMode +import com.aryan.reader.pdf.savePdfSystemUiMode +import com.aryan.reader.pdf.savePdfThemeId +import com.aryan.reader.pdf.savePdfVerticalPageGapVisible +import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible +import com.aryan.reader.pdf.loadPdfSystemUiMode +import com.aryan.reader.pdf.loadPdfThemeId +import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible +import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible +import com.aryan.reader.shared.BuiltInPdfReaderThemes +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode +import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition +import com.aryan.reader.shared.SharedSettingsAction +import com.aryan.reader.shared.SharedSettingsDestination +import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode +import com.aryan.reader.shared.parentDestination +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import com.aryan.reader.shared.readerThemeById +import com.aryan.reader.shared.sharedSettingsHubModel +import com.aryan.reader.shared.toReaderSettings +import com.aryan.reader.shared.ui.SharedSettingsHub +import com.aryan.reader.tts.loadTtsMode +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt + +private const val ANDROID_SETTINGS_GLOBAL_BOOK_ID = "__global_reader_defaults__" + +@androidx.annotation.OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: MainViewModel, + navController: NavHostController, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() + val ttsState by viewModel.ttsController.ttsState.collectAsStateWithLifecycle() + + var query by remember { mutableStateOf("") } + var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } + var showAppThemePanel by remember { mutableStateOf(false) } + var showBehaviorDialog by remember { mutableStateOf(false) } + var showStrictFilterDialog by remember { mutableStateOf(false) } + var showClearBookCacheDialog by remember { mutableStateOf(false) } + var showClearReflowCacheDialog by remember { mutableStateOf(false) } + var showClearAllDataDialog by remember { mutableStateOf(false) } + var showLanguageDialog by remember { mutableStateOf(false) } + var showAboutDialog by remember { mutableStateOf(false) } + var showSignOutConfirmDialog by remember { mutableStateOf(false) } + var showUpgradeDialog by remember { mutableStateOf(false) } + var showRecentLimitDialog by remember { mutableStateOf(false) } + var showTtsSettingsSheet by remember { mutableStateOf(false) } + var hideReaderAi by remember { mutableStateOf(loadHideReaderAiFeatures(context)) } + var epubReaderDefaults by remember(context, uiState.renderMode) { + mutableStateOf(loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode)) + } + var pdfReaderDefaults by remember(context) { + mutableStateOf(loadAndroidPdfReaderDefaultSettings(context)) + } + var ttsReplacementPreferences by remember(context) { + mutableStateOf(loadTtsReplacementPreferences(context)) + } + var ttsMode by remember(context) { mutableStateOf(loadTtsMode(context)) } + + LaunchedEffect(uiState.renderMode) { + epubReaderDefaults = loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode) + } + + val sharedFonts = remember(customFonts) { + customFonts.toSharedCustomFontItems() + } + + val settingsModel = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = uiState, + hideReaderAi = hideReaderAi + ) + ) + val settingsPage = settingsModel.page(settingsDestination) + + fun navigateBackFromSettings() { + if (query.isNotBlank()) { + query = "" + return + } + val parent = settingsDestination.parentDestination() + if (parent != null) { + settingsDestination = parent + } else { + onBackClick() + } + } + + BackHandler(enabled = query.isNotBlank() || settingsDestination != SharedSettingsDestination.ROOT) { + navigateBackFromSettings() + } + + Scaffold( + modifier = modifier, + topBar = { + CustomTopAppBar( + title = { Text(settingsPage.title) }, + navigationIcon = { + IconButton(onClick = ::navigateBackFromSettings) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + }, + contentWindowInsets = WindowInsets.navigationBars + ) { padding -> + SharedSettingsHub( + model = settingsModel, + query = query, + onQueryChange = { query = it }, + readerDefaultSettings = epubReaderDefaults, + onReaderDefaultSettingsChange = { settings -> + epubReaderDefaults = settings + saveAndroidEpubReaderDefaultSettings(context, settings) + viewModel.setRenderMode(settings.toAndroidRenderMode()) + }, + pdfReaderDefaultSettings = pdfReaderDefaults, + onPdfReaderDefaultSettingsChange = { settings -> + pdfReaderDefaults = settings + saveAndroidPdfReaderDefaultSettings(context, settings) + }, + ttsReplacementPreferences = ttsReplacementPreferences, + onTtsReplacementPreferencesChange = { preferences -> + ttsReplacementPreferences = preferences + saveTtsReplacementPreferences(context, preferences) + }, + customFonts = sharedFonts, + showTopBar = false, + destination = settingsDestination, + onDestinationChange = { settingsDestination = it }, + contentPadding = padding, + modifier = Modifier.fillMaxSize(), + onAction = { action -> + when (action) { + SharedSettingsAction.APP_THEME -> showAppThemePanel = true + SharedSettingsAction.LANGUAGE -> showLanguageDialog = true + SharedSettingsAction.TABS_TOGGLE -> viewModel.setTabsEnabled(!uiState.isTabsEnabled) + SharedSettingsAction.RECENT_LIMIT -> showRecentLimitDialog = true + SharedSettingsAction.STRICT_FILE_FILTER -> { + if (uiState.useStrictFileFilter) { + viewModel.setStrictFileFilter(false) + } else { + showStrictFilterDialog = true + } + } + SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true + SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> { + val next = !uiState.isScreenCaptureProtectionEnabled + viewModel.setScreenCaptureProtectionEnabled(next) + val messageRes = if (next) { + R.string.banner_screen_capture_protection_on + } else { + R.string.banner_screen_capture_protection_off + } + viewModel.showBanner(context.getString(messageRes)) + } + SharedSettingsAction.CUSTOM_FONTS -> navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE) + SharedSettingsAction.SIGN_IN -> { + scope.launch { + context.findActivity()?.let { activity -> viewModel.signIn(activity) } + } + } + SharedSettingsAction.SIGN_OUT -> showSignOutConfirmDialog = true + SharedSettingsAction.CLOUD_SYNC -> { + if (uiState.isProUser) { + viewModel.setSyncEnabled(!uiState.isSyncEnabled) + } else { + showUpgradeDialog = true + } + } + SharedSettingsAction.FOLDER_SYNC -> viewModel.setFolderSyncEnabled(!uiState.isFolderSyncEnabled) + SharedSettingsAction.DEVICE_MANAGEMENT -> viewModel.showDeviceManagementForDebug() + SharedSettingsAction.AI_SETTINGS -> navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) + SharedSettingsAction.HIDE_READER_AI -> { + val nextHidden = !hideReaderAi + saveHideReaderAiFeatures(context, nextHidden) + hideReaderAi = nextHidden + } + SharedSettingsAction.TTS_SETTINGS -> showTtsSettingsSheet = true + SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true + SharedSettingsAction.CLEAR_REFLOW_CACHE -> showClearReflowCacheDialog = true + SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> showClearAllDataDialog = true + SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context) + SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context) + SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context) + SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.") + SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE) + SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) + SharedSettingsAction.ABOUT -> showAboutDialog = true + SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.") + SharedSettingsAction.TEXT_READER_DEFAULTS, + SharedSettingsAction.READER_TOOLBAR, + SharedSettingsAction.TTS_REPLACEMENTS, + SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit + } + } + ) + } + + if (showRecentLimitDialog) { + RecentLimitDialog( + currentLimit = uiState.recentFilesLimit, + onSelect = { limit -> + viewModel.setRecentFilesLimit(limit) + showRecentLimitDialog = false + }, + onDismiss = { showRecentLimitDialog = false } + ) + } + + if (showBehaviorDialog) { + ExternalFileBehaviorDialog( + currentBehavior = uiState.externalFileBehavior, + onDismiss = { showBehaviorDialog = false }, + onSelect = { viewModel.setExternalFileBehavior(it) } + ) + } + + if (showStrictFilterDialog) { + StrictFilterConfirmationDialog( + onConfirm = { + viewModel.setStrictFileFilter(true) + showStrictFilterDialog = false + }, + onDismiss = { showStrictFilterDialog = false } + ) + } + + if (showClearBookCacheDialog) { + DangerousFolderActionDialog( + title = context.getString(R.string.dialog_clear_book_cache), + message = context.getString(R.string.dialog_clear_book_cache_desc), + onConfirm = { + viewModel.clearBookCache() + showClearBookCacheDialog = false + }, + onDismiss = { showClearBookCacheDialog = false } + ) + } + + if (showClearReflowCacheDialog) { + DangerousFolderActionDialog( + title = context.getString(R.string.dialog_clear_reflow_cache), + message = context.getString(R.string.dialog_clear_reflow_cache_desc), + onConfirm = { + viewModel.clearReflowCache() + showClearReflowCacheDialog = false + }, + onDismiss = { showClearReflowCacheDialog = false } + ) + } + + if (showClearAllDataDialog) { + ClearAllDataConfirmationDialog( + onConfirm = { + viewModel.deleteAllCloudAndLocalData() + showClearAllDataDialog = false + }, + onDismiss = { showClearAllDataDialog = false } + ) + } + + if (showLanguageDialog) { + LanguageSelectionDialog(onDismiss = { showLanguageDialog = false }) + } + + if (showAppThemePanel) { + AppThemeBottomSheet( + uiState = uiState, + onThemeModeChanged = viewModel::setAppThemeMode, + onContrastOptionChanged = viewModel::setAppContrastOption, + onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight, + onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark, + onSeedColorChanged = viewModel::setAppSeedColor, + onCustomThemeAdded = viewModel::addCustomAppTheme, + onCustomThemeDeleted = viewModel::deleteCustomAppTheme, + onDismiss = { showAppThemePanel = false } + ) + } + + if (showAboutDialog) { + AboutDialog(onDismiss = { showAboutDialog = false }) + } + + if (showSignOutConfirmDialog) { + SignOutConfirmationDialog( + onConfirm = { + viewModel.signOut() + showSignOutConfirmDialog = false + }, + onDismiss = { showSignOutConfirmDialog = false } + ) + } + + if (showUpgradeDialog) { + UpgradeDialog( + onConfirm = { + showUpgradeDialog = false + navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) + }, + onDismiss = { showUpgradeDialog = false } + ) + } + + if (showTtsSettingsSheet) { + TtsSettingsSheet( + isVisible = true, + onDismiss = { showTtsSettingsSheet = false }, + currentMode = ttsMode, + onModeChange = { mode -> + ttsMode = mode + viewModel.ttsController.changeTtsMode(mode.name) + }, + currentSpeakerId = ttsState.speakerId, + onSpeakerChange = viewModel.ttsController::changeSpeaker, + isTtsActive = ttsState.isPlaying, + getAuthToken = { viewModel.getAuthToken() }, + bookTitle = "Reader defaults" + ) + } + + if (uiState.deviceLimitState.isLimitReached) { + DeviceManagementScreen( + devices = uiState.deviceLimitState.registeredDevices, + onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) }, + isReplacing = uiState.isReplacingDevice + ) + } +} + +@Composable +private fun RecentLimitDialog( + currentLimit: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Recent files limit") }, + text = { + androidx.compose.foundation.layout.Column { + listOf(0, 10, 20, 50, 100).forEach { limit -> + TextButton(onClick = { onSelect(limit) }) { + val label = if (limit == 0) "No limit" else "$limit files" + Text(if (currentLimit == limit) "$label selected" else label) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +private fun loadAndroidEpubReaderDefaultSettings( + context: Context, + renderMode: RenderMode +): ReaderSettings { + val format = loadFormatSettings(context, ANDROID_SETTINGS_GLOBAL_BOOK_ID, isLocal = false) + val horizontalMargin = (48f * format.horizontalMargin).roundToInt().coerceIn(0, 160) + val verticalMargin = (48f * format.verticalMargin).roundToInt().coerceIn(0, 160) + val base = ReaderSettings( + fontSize = (18f * format.fontSize).roundToInt().coerceIn(12, 42), + lineSpacing = (1.45f * format.lineHeight).coerceIn(1.0f, 2.8f), + margin = max(horizontalMargin, verticalMargin), + readingMode = renderMode.toSharedReaderReadingMode(), + textAlign = format.textAlign.toSharedReaderTextAlign(), + fontFamily = format.toSharedFontFamilyName(), + paragraphSpacing = format.paragraphGap.coerceIn(0.5f, 2.5f), + imageScale = format.imageSize.coerceIn(0.5f, 2.0f), + horizontalMargin = horizontalMargin, + verticalMargin = verticalMargin, + themeId = loadReaderThemeId(context), + textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), + customFontPath = format.customPath?.takeIf { it.isNotBlank() }, + systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(), + pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(), + pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(), + seamlessChapterNavigation = loadPullToTurn(context), + chapterTurnDragMultiplier = loadPullToTurnMultiplier(context) + ) + return readerThemeById(base.themeId)?.toReaderSettings(base) ?: base +} + +private fun loadAndroidPdfReaderDefaultSettings( + context: Context +): ReaderSettings { + val base = ReaderSettings( + themeId = loadPdfThemeId(context), + textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), + systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(), + pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context), + pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context) + ) + return BuiltInPdfReaderThemes.firstOrNull { it.id == base.themeId }?.toReaderSettings(base) ?: base +} + +private fun saveAndroidEpubReaderDefaultSettings( + context: Context, + settings: ReaderSettings +) { + saveReaderSettings( + context = context, + fontSize = (settings.fontSize / 18f).coerceIn(0.65f, 2.4f), + lineHeight = (settings.lineSpacing / 1.45f).coerceIn(0.7f, 2.0f), + paragraphGap = settings.paragraphSpacing.coerceIn(0.5f, 2.5f), + imageSize = settings.imageScale.coerceIn(0.5f, 2.0f), + horizontalMargin = (settings.resolvedHorizontalMargin / 48f).coerceIn(0f, 3.4f), + verticalMargin = (settings.resolvedVerticalMargin / 48f).coerceIn(0f, 3.4f), + fontFamily = settings.toAndroidReaderFont(), + customFontPath = settings.customFontPath, + textAlign = settings.textAlign.toAndroidTextAlign() + ) + saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) + savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode()) + savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition()) + savePullToTurn(context, settings.seamlessChapterNavigation) + savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier) + saveReaderThemeId(context, settings.themeId ?: "system") + saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f)) +} + +private fun saveAndroidPdfReaderDefaultSettings( + context: Context, + settings: ReaderSettings +) { + savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) + savePdfThemeId(context, settings.themeId ?: "no_theme") + savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible) + savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible) + saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f)) +} + +private fun List.toSharedCustomFontItems(): List { + return filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + .map { font -> + CustomFontItem( + id = font.id, + displayName = font.displayName, + fileName = font.fileName, + fileExtension = font.fileExtension, + path = font.path, + timestamp = font.timestamp, + isDeleted = font.isDeleted + ) + } +} + +private fun AndroidFormatSettings.toSharedFontFamilyName(): String { + return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() } + ?: when (font) { + AndroidReaderFont.ORIGINAL -> "Default" + AndroidReaderFont.MERRIWEATHER, + AndroidReaderFont.LORA -> "Serif" + AndroidReaderFont.LATO, + AndroidReaderFont.LEXEND -> "Sans" + AndroidReaderFont.ROBOTO_MONO -> "Mono" + } +} + +private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign { + return when (this) { + AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY + AndroidReaderTextAlign.DEFAULT, + AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START + } +} + +private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont { + return when (fontFamily) { + "Serif" -> AndroidReaderFont.LORA + "Sans" -> AndroidReaderFont.LATO + "Mono" -> AndroidReaderFont.ROBOTO_MONO + else -> AndroidReaderFont.ORIGINAL + } +} + +private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign { + return when (this) { + SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY + SharedReaderTextAlign.CENTER, + SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT + } +} + +private fun RenderMode.toSharedReaderReadingMode(): ReaderReadingMode { + return when (this) { + RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED + RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL + } +} + +private fun ReaderSettings.toAndroidRenderMode(): RenderMode { + return when (readingMode) { + ReaderReadingMode.PAGINATED -> RenderMode.PAGINATED + ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL + } +} + +private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode { + return SharedSystemUiMode.valueOf(name) +} + +private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode { + return AndroidSystemUiMode.valueOf(name) +} + +private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode { + return SharedPageInfoMode.valueOf(name) +} + +private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode { + return AndroidPageInfoMode.valueOf(name) +} + +private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition { + return SharedPageInfoPosition.valueOf(name) +} + +private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition { + return AndroidPageInfoPosition.valueOf(name) +} diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 2498200..961b42d 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -23,6 +23,9 @@ package com.aryan.reader import android.content.Context import android.content.Intent import android.net.Uri +import android.text.TextUtils +import android.text.method.LinkMovementMethod +import android.widget.TextView import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -57,6 +60,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -69,17 +74,24 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.outlined.FileOpen import androidx.compose.material.icons.outlined.Gavel import androidx.compose.material.icons.outlined.Policy import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider @@ -88,6 +100,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProvideTextStyle @@ -101,7 +114,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -117,12 +132,19 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri +import androidx.core.text.HtmlCompat +import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.shared.ui.SharedMarkdownText import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date @@ -359,194 +381,493 @@ fun DeleteConfirmationDialog( } @Composable -fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) { - LocalContext.current +fun FileInfoDialog( + item: RecentFileItem, + onDismiss: () -> Unit, + onSaveMetadata: (BookMetadataEdit) -> Unit, + onSaveDisplayName: (String?) -> Unit, + onRestoreMetadata: () -> Unit, + onOpenTags: () -> Unit +) { @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current - - val originalName = item.title ?: item.displayName - var editingName by remember { mutableStateOf(item.customName ?: originalName) } - val hasCustomName = item.customName != null + val context = LocalContext.current + var isEditing by remember(item.bookId) { mutableStateOf(false) } + var titleInput by remember(item.bookId, item.title) { mutableStateOf(item.title.orEmpty()) } + var authorInput by remember(item.bookId, item.author) { mutableStateOf(item.author.orEmpty()) } + var seriesInput by remember(item.bookId, item.seriesName) { mutableStateOf(item.seriesName.orEmpty()) } + var seriesIndexInput by remember(item.bookId, item.seriesIndex) { + mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty()) + } + var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) } + var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) { + mutableStateOf(item.customName ?: item.cardTitle()) + } + var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) } val formattedDate = remember(item.timestamp) { SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp)) } - - val context = LocalContext.current + val lastModifiedDate = remember(item.lastModifiedTimestamp) { + item.lastModifiedTimestamp + .takeIf { it > 0L } + ?.let { SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(it)) } + } val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) { - if (isOpdsStream) { - "Source: OPDS Stream" - } else if (item.sourceFolderUri != null && item.uriString != null) { - try { - 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()) - } + item.resolveDisplayPath(context, isOpdsStream) + } + val pathTextFinal = if (isOpdsStream) { + stringResource(R.string.source_opds) + } else if (pathText == "In-App Storage") { + stringResource(R.string.source_in_app) + } else { + pathText.replace("Internal storage", stringResource(R.string.internal_storage)) + } + val hasOriginalMetadata = item.hasOriginalMetadata() + val hasMetadataChanges = item.hasMetadataChanges() + val canEditEmbeddedMetadata = item.type == FileType.EPUB && !isOpdsStream && item.uriString != null + val canRenameDisplayName = !canEditEmbeddedMetadata - val split = docId.split(":") - val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0] - 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" - } catch (_: Exception) { - val decoded = Uri.decode(item.uriString) - if (decoded.contains("primary:")) { - "/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/${item.displayName}" - } else { - item.displayName - } + Dialog( + onDismissRequest = { + if (isEditing) { + isEditing = false + } else { + onDismiss() + } + }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .imePadding() + ) { + FileInfoTopBar( + title = if (isEditing) { + if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app" + } else { + stringResource(R.string.file_information) + }, + subtitle = item.cardTitle(), + onClose = { + if (isEditing) { + isEditing = false + } else { + onDismiss() + } + } + ) + + HorizontalDivider() + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isEditing) { + if (canEditEmbeddedMetadata) { + BookMetadataEditContent( + titleInput = titleInput, + onTitleChange = { titleInput = it }, + authorInput = authorInput, + onAuthorChange = { authorInput = it }, + seriesInput = seriesInput, + onSeriesChange = { seriesInput = it }, + seriesIndexInput = seriesIndexInput, + onSeriesIndexChange = { seriesIndexInput = it }, + descriptionInput = descriptionInput, + onDescriptionChange = { descriptionInput = it } + ) + } else if (canRenameDisplayName) { + BookDisplayNameEditContent( + displayNameInput = displayNameInput, + onDisplayNameChange = { displayNameInput = it }, + originalFileName = item.displayName + ) + } + } else { + BookMetadataInfoContent( + item = item, + formattedDate = formattedDate, + lastModifiedDate = lastModifiedDate, + pathText = pathTextFinal, + hasMetadataChanges = hasMetadataChanges, + onCopy = { value -> clipboardManager.setText(AnnotatedString(value)) }, + onOpenTags = onOpenTags + ) + } + } + + HorizontalDivider() + + FileInfoBottomBar( + isEditing = isEditing, + canRestore = canEditEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing), + editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename", + onCancel = { + if (isEditing) { + isEditing = false + } else { + onDismiss() + } + }, + onRestore = { + showRestoreConfirmation = true + }, + onSave = { + if (canEditEmbeddedMetadata) { + onSaveMetadata( + BookMetadataEdit( + title = titleInput.toMetadataValue() ?: item.displayName.substringBeforeLast('.', item.displayName), + author = authorInput.toMetadataValue(), + seriesName = seriesInput.toMetadataValue(), + seriesIndex = seriesIndexInput.toSeriesIndexOrNull(), + description = descriptionInput.toMetadataValue() + ) + ) + } else if (canRenameDisplayName) { + onSaveDisplayName(displayNameInput.toMetadataValue()) + } + onDismiss() + }, + onEdit = { isEditing = true } + ) } - } else { - "In-App Storage" } } - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { + if (showRestoreConfirmation) { + AlertDialog( + onDismissRequest = { showRestoreConfirmation = false }, + icon = { Icon(Icons.Default.Restore, contentDescription = null) }, + title = { Text("Restore original metadata?") }, + text = { Text( - stringResource(R.string.file_information), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + "This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change." ) - - androidx.compose.material3.OutlinedTextField( - value = editingName, - onValueChange = { editingName = it }, - label = { Text(stringResource(R.string.book_name)) }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp, max = 130.dp), - maxLines = 4, - textStyle = MaterialTheme.typography.bodyLarge, - trailingIcon = { - IconButton(onClick = { - clipboardManager.setText(AnnotatedString(editingName)) - }) { - Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_name), modifier = Modifier.size(20.dp)) - } - } - ) - - if (hasCustomName) { - Text( - text = stringResource(R.string.original_name, originalName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - TextButton( - onClick = { - editingName = originalName - onUpdateName(null) - }, - modifier = Modifier.align(Alignment.End), - contentPadding = PaddingValues(0.dp) - ) { - Text(stringResource(R.string.revert_to_original)) - } - } else if (originalName != item.displayName) { - Text( - text = stringResource(R.string.file_name, item.displayName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { - InfoRowDetailed(stringResource(R.string.author), it) - } - item.seriesName?.takeIf { it.isNotBlank() }?.let { series -> - val seriesText = if (item.seriesIndex != null && item.seriesIndex > 0) { - "$series #${item.seriesIndex.toInt()}" - } else { - series - } - InfoRowDetailed("Series", seriesText) - } - InfoRowDetailed(stringResource(R.string.format), item.type.name) - InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize)) - InfoRowDetailed(stringResource(R.string.added), formattedDate) - - val pathTextFinal = if (isOpdsStream) { - stringResource(R.string.source_opds) - } else if (pathText == "In-App Storage") { - stringResource(R.string.source_in_app) - } else { - pathText.replace("Internal storage", stringResource(R.string.internal_storage)) - } - - InfoRowDetailed( - label = stringResource(R.string.location), - value = pathTextFinal, - maxLines = 4, - isScrollable = true, - onCopy = { - clipboardManager.setText(AnnotatedString(pathTextFinal)) - } - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.section_tags), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) } - } - - if (item.tags.isNotEmpty()) { - BookTagChipsRow(tags = item.tags, compact = false) - } else { - Text(stringResource(R.string.msg_no_tags_assigned), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - Spacer(modifier = Modifier.width(8.dp)) - androidx.compose.material3.Button(onClick = { - val finalName = editingName.trim() - if (finalName != (item.customName ?: originalName)) { - if (finalName == originalName || finalName.isEmpty()) { - onUpdateName(null) - } else { - onUpdateName(finalName) - } - } + }, + confirmButton = { + Button( + onClick = { + showRestoreConfirmation = false + onRestoreMetadata() onDismiss() - }) { Text(stringResource(R.string.action_save)) } + } + ) { + Text("Restore") + } + }, + dismissButton = { + TextButton(onClick = { showRestoreConfirmation = false }) { + Text(stringResource(R.string.action_cancel)) } } + ) + } +} + +@Composable +private fun FileInfoTopBar( + title: String, + subtitle: String, + onClose: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun BookMetadataInfoContent( + item: RecentFileItem, + formattedDate: String, + lastModifiedDate: String?, + pathText: String, + hasMetadataChanges: Boolean, + onCopy: (String) -> Unit, + onOpenTags: () -> Unit +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + item.cardTitle(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + item.author + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?.let { + Text( + it, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + val provenance = when { + item.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited" + item.type == FileType.EPUB -> "Metadata from EPUB file" + !item.customName.isNullOrBlank() -> "Display name changed in app" + else -> "Metadata from file" + } + Text( + provenance, + style = MaterialTheme.typography.labelMedium, + color = if (hasMetadataChanges) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + FileInfoSection(title = "Metadata") { + InfoRowDetailed("Title", item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3) + item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { + InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2) + } + item.seriesLabel()?.let { + InfoRowDetailed("Series", it, maxLines = 2) + } + InfoRowDetailed(stringResource(R.string.format), item.type.name) + InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize)) + InfoRowDetailed("Reading", item.readingProgressText(), maxLines = 2) + } + + FileInfoSection(title = "File") { + InfoRowDetailed("File name", item.displayName, maxLines = 2) + InfoRowDetailed(stringResource(R.string.added), formattedDate) + lastModifiedDate?.let { InfoRowDetailed("Modified", it) } + InfoRowDetailed( + label = stringResource(R.string.location), + value = pathText, + maxLines = 4, + onCopy = { onCopy(pathText) } + ) + } + + item.description?.takeIf { it.isNotBlank() }?.let { summary -> + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Summary", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + ExpandableSummaryText(summary, collapsedMaxLines = 4) + } + } + } + + FileInfoSection(title = stringResource(R.string.section_tags)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Library tags", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) } + } + + if (item.tags.isNotEmpty()) { + BookTagChipsRow(tags = item.tags, compact = false) + } else { + Text( + stringResource(R.string.msg_no_tags_assigned), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun BookMetadataEditContent( + titleInput: String, + onTitleChange: (String) -> Unit, + authorInput: String, + onAuthorChange: (String) -> Unit, + seriesInput: String, + onSeriesChange: (String) -> Unit, + seriesIndexInput: String, + onSeriesIndexChange: (String) -> Unit, + descriptionInput: String, + onDescriptionChange: (String) -> Unit +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Editable metadata", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = titleInput, + onValueChange = onTitleChange, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + OutlinedTextField( + value = authorInput, + onValueChange = onAuthorChange, + label = { Text(stringResource(R.string.author)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2 + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = seriesInput, + onValueChange = onSeriesChange, + label = { Text("Series") }, + modifier = Modifier.weight(1f), + maxLines = 2 + ) + OutlinedTextField( + value = seriesIndexInput, + onValueChange = onSeriesIndexChange, + label = { Text("#") }, + modifier = Modifier.width(96.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + ) + } + OutlinedTextField( + value = descriptionInput, + onValueChange = onDescriptionChange, + label = { Text("Summary") }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + minLines = 4, + maxLines = 10 + ) + } + } +} + +@Composable +private fun BookDisplayNameEditContent( + displayNameInput: String, + onDisplayNameChange: (String) -> Unit, + originalFileName: String +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Display name", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = displayNameInput, + onValueChange = onDisplayNameChange, + label = { Text("Name shown in Reader") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + Text( + "Original file: $originalFileName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun FileInfoBottomBar( + isEditing: Boolean, + canRestore: Boolean, + editLabel: String, + onCancel: () -> Unit, + onRestore: () -> Unit, + onSave: () -> Unit, + onEdit: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (canRestore) { + OutlinedButton( + onClick = onRestore, + modifier = Modifier.padding(end = 8.dp) + ) { + Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Restore") + } + } + TextButton(onClick = onCancel) { + Text(if (isEditing) stringResource(R.string.action_cancel) else stringResource(R.string.action_close)) + } + Spacer(modifier = Modifier.width(8.dp)) + if (isEditing) { + Button(onClick = onSave) { + Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_save)) + } + } else { + Button(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(editLabel) + } + } + } +} + +@Composable +private fun FileInfoSection( + title: String, + content: @Composable () -> Unit +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + content() } } } @@ -556,7 +877,6 @@ private fun InfoRowDetailed( label: String, value: String, maxLines: Int = 1, - isScrollable: Boolean = false, onCopy: (() -> Unit)? = null ) { Row( @@ -569,32 +889,17 @@ private fun InfoRowDetailed( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .width(85.dp) + .width(104.dp) .padding(top = 2.dp) ) - - val scrollModifier = if (isScrollable) { - Modifier - .heightIn(max = 66.dp) - .verticalScroll(rememberScrollState()) - } else Modifier - - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - 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) - ) + Column(modifier = Modifier.weight(1f)) { + ExpandableValueText(value, collapsedMaxLines = maxLines) + } if (onCopy != null) { IconButton( onClick = onCopy, modifier = Modifier - .size(24.dp) + .size(28.dp) .padding(start = 4.dp) ) { Icon( @@ -608,6 +913,211 @@ private fun InfoRowDetailed( } } +@Composable +private fun ExpandableValueText( + value: String, + collapsedMaxLines: Int +) { + var expanded by remember(value) { mutableStateOf(false) } + val canExpand = collapsedMaxLines < Int.MAX_VALUE && (value.length > 120 || value.contains('\n')) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines, + overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + if (canExpand) { + TextButton( + onClick = { expanded = !expanded }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(32.dp) + ) { + Text(if (expanded) "Less" else "...more") + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun ExpandableSummaryText( + value: String, + collapsedMaxLines: Int +) { + var expanded by remember(value) { mutableStateOf(false) } + val canExpand = value.length > 220 || value.count { it == '\n' } >= collapsedMaxLines || value.looksLikeHtml() + val contentModifier = if (expanded) { + Modifier.fillMaxWidth() + } else { + Modifier + .fillMaxWidth() + .heightIn(max = (collapsedMaxLines * 26).dp) + .clipToBounds() + } + + if (value.looksLikeHtml()) { + HtmlSummaryText( + html = value, + expanded = expanded, + collapsedMaxLines = collapsedMaxLines, + modifier = Modifier.fillMaxWidth() + ) + } else { + Box(modifier = contentModifier) { + SharedMarkdownText( + markdown = value, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + if (canExpand) { + TextButton( + onClick = { expanded = !expanded }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(32.dp) + ) { + Text(if (expanded) "Less" else "...more") + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun HtmlSummaryText( + html: String, + expanded: Boolean, + collapsedMaxLines: Int, + modifier: Modifier = Modifier +) { + val textColor = MaterialTheme.colorScheme.onSurface.toArgb() + val linkColor = MaterialTheme.colorScheme.primary.toArgb() + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + includeFontPadding = false + movementMethod = LinkMovementMethod.getInstance() + } + }, + update = { textView -> + textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) + textView.setTextColor(textColor) + textView.setLinkTextColor(linkColor) + textView.maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines + textView.ellipsize = if (expanded) null else TextUtils.TruncateAt.END + } + ) +} + +private fun RecentFileItem.resolveDisplayPath(context: Context, isOpdsStream: Boolean): String { + return if (isOpdsStream) { + "Source: OPDS Stream" + } else if (sourceFolderUri != null && uriString != null) { + try { + val uri = 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] + var relativePath = if (split.size > 1) Uri.decode(split[1]).removeSuffix("/") else "" + + if (!relativePath.endsWith(displayName)) { + relativePath = if (relativePath.isEmpty()) displayName else "$relativePath/$displayName" + } + + val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else "" + "/$storageName$leadingSlash$relativePath" + } catch (_: Exception) { + val decoded = Uri.decode(uriString) + if (decoded.contains("primary:")) { + "/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/$displayName" + } else { + displayName + } + } + } else { + "In-App Storage" + } +} + +private fun String.looksLikeHtml(): Boolean { + return contains(Regex("<\\s*/?\\s*(p|br|div|span|strong|em|ul|ol|li|h[1-6]|blockquote|a|b|i)\\b", RegexOption.IGNORE_CASE)) || + contains(Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);")) +} + +private fun RecentFileItem.hasOriginalMetadata(): Boolean { + return listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription).any { !it.isNullOrBlank() } || + originalSeriesIndex != null +} + +private fun RecentFileItem.hasMetadataChanges(): Boolean { + return metadataValueChanged(title, originalTitle) || + metadataValueChanged(author, originalAuthor) || + metadataValueChanged(seriesName, originalSeriesName) || + seriesIndex != originalSeriesIndex || + metadataValueChanged(description, originalDescription) || + !customName.isNullOrBlank() +} + +private fun metadataValueChanged(current: String?, original: String?): Boolean { + return current.orEmpty().trim() != original.orEmpty().trim() +} + +private fun RecentFileItem.seriesLabel(): String? { + val series = seriesName?.trim()?.takeIf { it.isNotBlank() } ?: return null + return seriesIndex?.takeIf { it > 0.0 }?.let { "$series #${it.formatMetadataNumber()}" } ?: series +} + +private fun RecentFileItem.readingProgressText(): String { + val progress = progressPercentage?.coerceIn(0f, 100f) + val progressText = progress?.let { String.format(Locale.US, "%.1f%%", it) } ?: "Not started" + val locatorText = when { + lastPage != null -> "Last page ${lastPage + 1}" + lastChapterIndex != null -> "Chapter ${lastChapterIndex + 1}" + else -> null + } + return listOfNotNull(progressText, locatorText).joinToString(" - ") +} + +private fun String.toMetadataValue(): String? { + return trim().takeIf { it.isNotEmpty() } +} + +private fun String.toSeriesIndexOrNull(): Double? { + return trim() + .replace(',', '.') + .takeIf { it.isNotEmpty() } + ?.toDoubleOrNull() + ?.takeIf { it > 0.0 } +} + +private fun Double.formatMetadataNumber(): String { + return if (this % 1.0 == 0.0) { + toInt().toString() + } else { + String.format(Locale.US, "%.2f", this).trimEnd('0').trimEnd('.') + } +} + @Composable fun CustomTopBanner(bannerMessage: BannerMessage?) { AnimatedVisibility( @@ -931,7 +1441,7 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null ) { Text( - text = type.name.uppercase(), + text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(), style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), fontWeight = FontWeight.ExtraBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt index 4ed229f..abc0c11 100644 --- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt +++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt @@ -17,9 +17,10 @@ import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter import com.aryan.reader.shared.RenderMode as SharedRenderMode -import com.aryan.reader.shared.SharedLibraryProjectionInput import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.Shelf as SharedShelf import com.aryan.reader.shared.ShelfRecord +import com.aryan.reader.shared.ShelfType as SharedShelfType import com.aryan.reader.shared.SortOrder as SharedSortOrder import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.Tag as SharedTag @@ -34,18 +35,88 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem { coverImagePath = coverImagePath, title = title, author = author, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, progressPercentage = progressPercentage, isRecent = isRecent, fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, sourceFolder = sourceFolderUri, folderTextMetadataParsed = folderTextMetadataParsed, seriesName = seriesName, seriesIndex = seriesIndex, + lastPageIndex = lastPage, tags = tags.map { it.toSharedTag() }, readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) ) } +fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem { + return toSharedBookItem().copy(displayName = displayName) +} + +fun SharedBookItem.toRecentFileItem( + androidBooksById: Map = emptyMap(), + tagEntitiesById: Map = emptyMap() +): RecentFileItem { + val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) } + return androidBooksById[id]?.copy(tags = resolvedTags) + ?.copy( + uriString = path, + type = type.toAndroidFileType(), + displayName = androidBooksById[id]?.displayName ?: displayName, + timestamp = timestamp, + coverImagePath = coverImagePath, + title = title, + author = author, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + lastPage = lastPageIndex, + progressPercentage = progressPercentage, + isRecent = isRecent, + sourceFolderUri = sourceFolder, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + seriesName = seriesName, + seriesIndex = seriesIndex, + folderTextMetadataParsed = folderTextMetadataParsed + ) + ?: RecentFileItem( + bookId = id, + uriString = path, + type = type.toAndroidFileType(), + displayName = displayName, + timestamp = timestamp, + coverImagePath = coverImagePath, + title = title, + author = author, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + lastPage = lastPageIndex, + progressPercentage = progressPercentage, + isRecent = isRecent, + sourceFolderUri = sourceFolder, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + seriesName = seriesName, + seriesIndex = seriesIndex, + folderTextMetadataParsed = folderTextMetadataParsed, + tags = resolvedTags + ) +} + fun TagEntity.toSharedTag(): SharedTag { return SharedTag( id = id, @@ -153,92 +224,147 @@ fun ReaderScreenState.toSharedReaderScreenState( ) } -fun ReaderScreenState.toSharedLibraryProjectionInput( - recentFilesFromDb: List, - dbShelves: List, - shelfRefs: List, +fun List.withResolvedTags( dbTags: List, tagRefs: List -): SharedLibraryProjectionInput { +): List { val tagsById = dbTags.associateBy { it.id } val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> entry.value.mapNotNull { tagsById[it.tagId] } } - val taggedBooks = recentFilesFromDb.map { item -> - item.copy(tags = bookTagsMap[item.bookId].orEmpty()) + return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) } +} + +fun SharedReaderScreenState.toAndroidReaderScreenState( + base: ReaderScreenState, + androidBooksById: Map, + tagEntitiesById: Map = emptyMap() +): ReaderScreenState { + val fallbackBooksById = rawLibraryBooks.associateBy { it.id } + fun SharedBookItem.toAndroidBook(): RecentFileItem { + return toRecentFileItem(androidBooksById, tagEntitiesById) } - return SharedLibraryProjectionInput( - state = toSharedReaderScreenState( - rawBooks = taggedBooks, - dbTags = dbTags - ), - booksFromStore = taggedBooks - .filterNot { it.bookId.endsWith("_reflow") } - .map { it.toSharedBookItem() }, - shelfRecords = dbShelves.map { it.toSharedShelfRecord() }, - shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() }, - tags = dbTags.map { it.toSharedTag() } + fun bookById(bookId: String): RecentFileItem? { + return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook() + } + return base.copy( + recentFiles = recentBooks.map { it.toAndroidBook() }, + allRecentFiles = libraryBooks.map { it.toAndroidBook() }, + rawLibraryFiles = rawLibraryBooks.map { it.toAndroidBook() }, + viewingShelfId = viewingShelfId, + isAddingBooksToShelf = isAddingBooksToShelf, + contextualActionShelfIds = selectedShelfIds, + contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) }, + shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) }, + openTabs = openTabs.map { it.toAndroidBook() }, + openTabIds = openTabIds, + activeTabBookId = activeTabBookId, + booksAvailableForAdding = booksAvailableForAdding.map { it.toAndroidBook() }, + allTags = allTags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) } + ) +} + +fun SharedShelf.toAndroidShelf( + androidBooksById: Map = emptyMap(), + tagEntitiesById: Map = emptyMap() +): Shelf { + return Shelf( + id = id, + name = name, + type = type.toAndroidShelfType(), + books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, + directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, + parentShelfId = parentShelfId, + childShelfIds = childShelfIds, + depth = depth, + sortKey = sortKey ) } fun FileType.toSharedFileType(): SharedFileType { - return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN) + return this } -private fun RenderMode.toSharedRenderMode(): SharedRenderMode { - return SharedRenderMode.valueOf(name) +fun SharedFileType.toAndroidFileType(): FileType { + return this } -private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource { - return SharedAddBooksSource.valueOf(name) +fun RenderMode.toSharedRenderMode(): SharedRenderMode { + return this } -private fun SortOrder.toSharedSortOrder(): SharedSortOrder { - return SharedSortOrder.valueOf(name) +fun SharedRenderMode.toAndroidRenderMode(): RenderMode { + return this } -private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter { - return SharedReadStatusFilter.valueOf(name) +fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource { + return this } -private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters { - return SharedLibraryFilters( - fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }, - sourceFolders = sourceFolders, - readStatus = readStatus.toSharedReadStatusFilter(), - tagIds = tagIds - ) +fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource { + return this } -private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder { - return SharedSyncedFolder( - uriString = uriString, - name = name, - lastScanTime = lastScanTime, - allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() } - ) +fun SortOrder.toSharedSortOrder(): SharedSortOrder { + return this } -private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage { - return SharedBannerMessage( - message = message, - isError = isError, - isPersistent = isPersistent - ) +fun SharedSortOrder.toAndroidSortOrder(): SortOrder { + return this } -private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode { - return SharedAppThemeMode.valueOf(name) +fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter { + return this } -private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption { - return SharedAppContrastOption.valueOf(name) +fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter { + return this } -private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme { - return SharedCustomAppTheme( - id = id, - name = name, - seedColor = seedColor - ) +fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters { + return this +} + +fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters { + return this +} + +fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder { + return this +} + +fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder { + return this +} + +private fun SharedShelfType.toAndroidShelfType(): ShelfType { + return ShelfType.valueOf(name) +} + +fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage { + return this +} + +fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode { + return this +} + +fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode { + return this +} + +fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption { + return this +} + +fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption { + return this +} + +fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme { + return this +} + +fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme { + return this } diff --git a/app/src/main/java/com/aryan/reader/ThemedBookCover.kt b/app/src/main/java/com/aryan/reader/ThemedBookCover.kt new file mode 100644 index 0000000..1cfbfcc --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ThemedBookCover.kt @@ -0,0 +1,191 @@ +package com.aryan.reader + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.aryan.reader.data.RecentFileItem +import java.io.File +import kotlin.math.absoluteValue + +@Composable +fun ThemedBookCover( + item: RecentFileItem, + modifier: Modifier = Modifier, + contentDescription: String? = item.displayName, + contentScale: ContentScale = ContentScale.Crop +) { + val context = LocalContext.current + val coverFile = remember(item.coverImagePath) { + item.coverImagePath + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?.takeIf { it.isFile } + } + + Box(modifier = modifier) { + GeneratedBookCover(item = item, modifier = Modifier.fillMaxSize()) + if (coverFile != null) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(coverFile) + .crossfade(true) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +private fun GeneratedBookCover( + item: RecentFileItem, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val seed = remember(item.bookId, item.displayName) { + val hash = (item.bookId.ifBlank { item.displayName }).hashCode() + if (hash == Int.MIN_VALUE) 0 else hash.absoluteValue + } + val baseOptions = listOf( + colorScheme.primaryContainer, + colorScheme.secondaryContainer, + colorScheme.tertiaryContainer, + lerp(colorScheme.primary, colorScheme.surface, 0.30f), + lerp(colorScheme.secondary, colorScheme.surface, 0.26f) + ) + val accentOptions = listOf( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + colorScheme.inversePrimary + ) + val base = baseOptions[seed % baseOptions.size] + val accent = accentOptions[(seed / 7) % accentOptions.size] + val title = item.coverTitle() + val author = item.coverAuthor() + + BoxWithConstraints( + modifier = modifier + .background( + Brush.linearGradient( + colors = listOf( + lerp(base, colorScheme.surface, 0.06f), + lerp(base, accent, 0.16f), + lerp(colorScheme.surfaceContainerHighest, base, 0.34f) + ) + ) + ) + .border(0.5.dp, colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + val compact = maxWidth < 80.dp + Box( + modifier = Modifier + .fillMaxHeight() + .width(if (compact) 7.dp else 10.dp) + .background(accent.copy(alpha = 0.42f)) + .align(Alignment.CenterStart) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.72f) + .height(if (compact) 5.dp else 7.dp) + .align(Alignment.TopEnd) + .offset(y = if (compact) 8.dp else 12.dp) + .background(colorScheme.surface.copy(alpha = 0.30f)) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.48f) + .height(if (compact) 4.dp else 6.dp) + .align(Alignment.BottomStart) + .offset(x = if (compact) 12.dp else 18.dp, y = if (compact) (-10).dp else (-16).dp) + .background(accent.copy(alpha = 0.26f)) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = if (compact) 12.dp else 18.dp, + top = if (compact) 10.dp else 18.dp, + end = if (compact) 8.dp else 14.dp, + bottom = if (compact) 10.dp else 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + color = colorScheme.onSurface, + fontSize = if (compact) 12.sp else 17.sp, + lineHeight = if (compact) 14.sp else 20.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + if (author != null && !compact) { + Spacer(modifier = Modifier.height(10.dp)) + Box( + modifier = Modifier + .width(36.dp) + .height(1.dp) + .background(accent.copy(alpha = 0.55f)) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = author, + color = colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +private fun RecentFileItem.coverTitle(): String { + return customName + ?.takeIf { it.isNotBlank() } + ?: title?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) } + ?: displayName.substringBeforeLast('.', missingDelimiterValue = displayName) +} + +private fun RecentFileItem.coverAuthor(): String? { + return author + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } +} 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 3f608b9..15a2799 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase TagEntity::class, BookTagCrossRef::class ], - version = 19, + version = 22, exportSchema = false ) @TypeConverters(FileTypeConverter::class) @@ -257,6 +257,37 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN folderCoverMetadataParsed INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN originalTitle TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN originalAuthor TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesName TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesIndex REAL DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN originalDescription TEXT DEFAULT NULL") + db.execSQL(""" + UPDATE recent_files + SET + originalTitle = title, + originalAuthor = author, + originalSeriesName = seriesName, + originalSeriesIndex = seriesIndex, + originalDescription = description + """) + } + } + + val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN fileContentModifiedTimestamp INTEGER NOT NULL DEFAULT 0") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -269,7 +300,8 @@ abstract class AppDatabase : RoomDatabase() { 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_15_16, - MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19 + MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, + MIGRATION_20_21, MIGRATION_21_22 ) .fallbackToDestructiveMigration(false) .build() diff --git a/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt b/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt new file mode 100644 index 0000000..3e403fe --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt @@ -0,0 +1,9 @@ +package com.aryan.reader.data + +data class BookMetadataEdit( + val title: String?, + val author: String?, + val seriesName: String?, + val seriesIndex: Double?, + val description: String? +) 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 81b3d19..260890c 100644 --- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt +++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt @@ -20,13 +20,19 @@ data class FolderBookMetadata( val locatorBlockIndex: Int?, val locatorCharOffset: Int?, val customName: String?, - val highlightsJson: String? + val highlightsJson: String?, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null ) { fun toJsonString(): String { val json = JSONObject() json.put("bookId", bookId) - json.put("title", title) - json.put("author", author) json.put("displayName", displayName) json.put("type", type) json.put("lastChapterIndex", lastChapterIndex ?: -1) @@ -58,8 +64,8 @@ data class FolderBookMetadata( return FolderBookMetadata( bookId = json.getString("bookId"), - title = json.optStringNull("title"), - author = json.optStringNull("author"), + title = null, + author = null, displayName = json.optString("displayName", "Unknown"), type = json.optString("type", "PDF"), lastChapterIndex = json.optIntNull("lastChapterIndex"), @@ -72,7 +78,15 @@ data class FolderBookMetadata( locatorBlockIndex = json.optIntNull("locatorBlockIndex"), locatorCharOffset = json.optIntNull("locatorCharOffset"), customName = json.optStringNull("customName"), - highlightsJson = json.optStringNull("highlightsJson") + highlightsJson = json.optStringNull("highlightsJson"), + seriesName = null, + seriesIndex = null, + description = null, + originalTitle = null, + originalAuthor = null, + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null ) } } @@ -86,8 +100,8 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, displayName = this.displayName, timestamp = System.currentTimeMillis(), coverImagePath = coverPath, - title = this.title, - author = this.author, + title = this.displayName.substringBeforeLast('.', this.displayName), + author = null, lastChapterIndex = this.lastChapterIndex, lastPage = this.lastPage, lastPositionCfi = this.lastPositionCfi, @@ -101,6 +115,14 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, bookmarksJson = this.bookmarksJson, sourceFolderUri = sourceFolderUri, customName = this.customName, - highlightsJson = this.highlightsJson + highlightsJson = this.highlightsJson, + seriesName = null, + seriesIndex = null, + description = null, + originalTitle = null, + originalAuthor = null, + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index 559a902..889d5a1 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -7,6 +7,12 @@ import android.os.Environment import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile import com.aryan.reader.ReaderPerfLog +import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX +import com.aryan.reader.shared.localFolderSyncAnnotationFileName +import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName +import com.aryan.reader.shared.localFolderSyncMetadataFileName +import com.aryan.reader.shared.localFolderSyncMetadataTempFileName +import com.aryan.reader.shared.localFolderSyncSidecarStem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -22,6 +28,12 @@ object LocalSyncUtils { val uri: Uri ) + private data class ParsedAnnotationSidecar( + val bookId: String, + val timestamp: Long, + val data: String + ) + private fun syncSubfolderDocId(rootDocId: String): String { return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) { rootDocId @@ -182,15 +194,14 @@ object LocalSyncUtils { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext - val syncFileName = ".${metadata.bookId}.json" + val syncFileName = localFolderSyncMetadataFileName(metadata.bookId) val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId) if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") return@withContext } - val tempFileName = ".${metadata.bookId}.tmp" - syncDir.findFile(tempFileName)?.delete() + val tempFileName = uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId)) val tempFile = syncDir.createFile("application/json", tempFileName) if (tempFile == null) { Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") @@ -258,10 +269,8 @@ object LocalSyncUtils { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) - val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" - val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" - syncDir.findFile(tempName)?.delete() - val tempFile = syncDir.createFile("application/json", tempName) + val targetName = localFolderSyncAnnotationFileName(bookId) + val tempName = uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId)) if (currentBest != null) { val (remoteTs, _) = currentBest @@ -273,10 +282,12 @@ object LocalSyncUtils { val wrapper = JSONObject() wrapper.put("version", 1) + wrapper.put("bookId", bookId) wrapper.put("timestamp", timestamp) wrapper.put("data", JSONObject(jsonPayload)) val contentBytes = wrapper.toString().toByteArray() + val tempFile = syncDir.createFile("application/json", tempName) if (tempFile == null) { Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") return@withContext @@ -325,17 +336,21 @@ object LocalSyncUtils { val results = mutableMapOf>() try { - val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri) - .filter { file -> - val name = file.name - extractAnnotationBookId(name) != null && - !name.contains(".syncthing.") + val parsedSidecars = querySyncSubfolderFiles(context, sourceFolderUri) + .asSequence() + .filter { isAnnotationSidecarCandidateName(it.name) } + .mapNotNull { file -> + parseAnnotationSidecar( + context = context, + file = file, + fallbackBookId = extractLegacyAnnotationBookId(file.name) + ) } - .groupBy { file -> extractAnnotationBookId(file.name).orEmpty() } + .groupBy { it.bookId } - for ((bookId, files) in groupedFiles) { - val best = resolveAnnotationConflictsReadOnly(context, bookId, files) - if (best != null) results[bookId] = best + for ((bookId, sidecars) in parsedSidecars) { + val best = sidecars.maxByOrNull { it.timestamp } + if (best != null) results[bookId] = best.timestamp to best.data } } catch (e: Exception) { Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars") @@ -344,44 +359,6 @@ object LocalSyncUtils { return@withContext results } - private fun resolveAnnotationConflictsReadOnly( - context: Context, - bookId: String, - files: List - ): Pair? { - val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" - val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" - var bestTs = -1L - var bestData: String? = null - - for (file in files) { - val name = file.name - if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) && - name.endsWith(".json") && - !name.endsWith(".tmp") && - !name.contains(".syncthing.")) - ) { - continue - } - try { - val content = context.contentResolver.openInputStream(file.uri)?.use { - it.bufferedReader().readText() - } ?: continue - val json = JSONObject(content) - val ts = json.optLong("timestamp", 0L) - val data = json.optJSONObject("data")?.toString() - if (data != null && ts > bestTs) { - bestTs = ts - bestData = data - } - } catch (e: Exception) { - Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name") - } - } - - return bestData?.let { bestTs to it } - } - suspend fun getAnnotationSidecar( context: Context, sourceFolderUri: Uri, @@ -405,17 +382,16 @@ object LocalSyncUtils { bookId: String, knownFiles: List? = null ): Pair? { - val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" - val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" - val allFiles = knownFiles ?: syncDir.listFiles().asList() - val candidates = allFiles.filter { file -> - val name = file.name ?: "" - (name.startsWith(basePattern) || name.startsWith(legacyPattern)) && - name.endsWith(".json") && - !name.endsWith(".tmp") && - !name.contains(".syncthing.") + val candidates = allFiles.mapNotNull { file -> + val name = file.name ?: return@mapNotNull null + if (!isAnnotationSidecarCandidateName(name)) return@mapNotNull null + parseAnnotationSidecar( + context = context, + file = SyncFileEntry(name = name, uri = file.uri), + fallbackBookId = extractLegacyAnnotationBookId(name) + )?.takeIf { it.bookId == bookId }?.let { file to it } } if (candidates.isEmpty()) return null @@ -425,31 +401,16 @@ object LocalSyncUtils { var bestFile: DocumentFile? = null val filesToDelete = mutableListOf() - for (file in candidates) { - try { - val content = context.contentResolver.openInputStream(file.uri)?.use { - it.bufferedReader().readText() - } ?: continue - - val json = JSONObject(content) - val ts = json.optLong("timestamp", 0L) - val data = json.optJSONObject("data")?.toString() - - if (data != null) { - if (ts > bestTs) { - if (bestFile != null) filesToDelete.add(bestFile) - - bestTs = ts - bestData = data - bestFile = file - } else { - filesToDelete.add(file) - } - } else { - filesToDelete.add(file) + for ((file, sidecar) in candidates) { + if (sidecar.timestamp > bestTs) { + if (bestFile != null) { + filesToDelete.add(bestFile) } - } catch (e: Exception) { - Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}") + bestTs = sidecar.timestamp + bestData = sidecar.data + bestFile = file + } else { + filesToDelete.add(file) } } @@ -464,7 +425,7 @@ object LocalSyncUtils { } if (bestFile != null) { - val correctName = "${basePattern}.json" + val correctName = localFolderSyncAnnotationFileName(bookId) if (bestFile.name != correctName) { Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName") val existingTarget = syncDir.findFile(correctName) @@ -548,7 +509,7 @@ object LocalSyncUtils { } } - val correctName = ".${bookId}.json" + val correctName = localFolderSyncMetadataFileName(bookId) if (bestFile.name != correctName) { Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName") bestFile.renameTo(correctName) @@ -563,21 +524,24 @@ object LocalSyncUtils { syncDir: DocumentFile, bookId: String ): FolderBookMetadata? { + val hashedStem = localFolderSyncSidecarStem(bookId) val candidates = syncDir.listFiles().filter { file -> val name = file.name ?: "" - val normalizedName = if (name.startsWith(".")) name.substring(1) else name - normalizedName == "$bookId.json" || - normalizedName.startsWith("$bookId.sync-conflict") || - normalizedName.startsWith("$bookId.json.sync-conflict") + val normalizedName = name.normalizedSidecarName() + isMetadataSidecarCandidateName(name) && + ( + normalizedName.matchesJsonSidecarStem(hashedStem) || + normalizedName.matchesJsonSidecarStem(bookId) + ) } if (candidates.isEmpty()) return null return resolveAndCleanConflicts(context, candidates, bookId) } - private fun extractAnnotationBookId(name: String?): String? { + private fun extractLegacyAnnotationBookId(name: String?): String? { if (name.isNullOrBlank()) return null var temp = name - if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null + if (!isAnnotationSidecarCandidateName(temp)) return null if (temp.contains(".sync-conflict")) { temp = temp.substringBefore(".sync-conflict") } @@ -588,9 +552,63 @@ object LocalSyncUtils { if (temp.startsWith(".")) { temp = temp.substring(1) } + if (temp.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null return temp.ifBlank { null } } + private fun isMetadataSidecarCandidateName(name: String): Boolean { + if (name.contains(ANNOTATION_SUFFIX)) return false + if (name.contains(".tmp") || name.contains(".syncthing.")) return false + return name.endsWith(".json") || name.contains(".sync-conflict") + } + + private fun isAnnotationSidecarCandidateName(name: String): Boolean { + if (!name.contains(ANNOTATION_SUFFIX)) return false + if (name.contains(".tmp") || name.contains(".syncthing.")) return false + return name.endsWith(".json") || name.contains(".sync-conflict") + } + + private fun String.normalizedSidecarName(): String { + return removePrefix(".") + } + + private fun String.matchesJsonSidecarStem(stem: String): Boolean { + return this == "$stem.json" || + startsWith("$stem.sync-conflict") || + startsWith("$stem.json.sync-conflict") + } + + private fun uniqueFolderSyncTempName(baseName: String): String { + val stem = baseName.removeSuffix(".tmp") + val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}" + return "$stem.$nonce.tmp" + } + + private fun parseAnnotationSidecar( + context: Context, + file: SyncFileEntry, + fallbackBookId: String? + ): ParsedAnnotationSidecar? { + return try { + val content = context.contentResolver.openInputStream(file.uri)?.use { + it.bufferedReader().readText() + } ?: return null + val json = JSONObject(content) + val bookId = json.optString("bookId").takeIf { it.isNotBlank() } + ?: fallbackBookId + ?: return null + val data = json.optJSONObject("data")?.toString() ?: return null + ParsedAnnotationSidecar( + bookId = bookId, + timestamp = json.optLong("timestamp", 0L), + data = data + ) + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: ${file.name}") + null + } + } + suspend fun deleteBookSidecars( context: Context, sourceFolderUri: Uri, @@ -599,13 +617,15 @@ object LocalSyncUtils { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext + val hashedStem = localFolderSyncSidecarStem(bookId) + val hashedAnnotationStem = "$hashedStem$ANNOTATION_SUFFIX" val targets = syncDir.listFiles().filter { file -> val name = file.name ?: return@filter false - val normalized = if (name.startsWith(".")) name.substring(1) else name - normalized == "$bookId.json" || - normalized.startsWith("$bookId.sync-conflict") || - normalized.startsWith("$bookId.json.sync-conflict") || - normalized.startsWith("$bookId${ANNOTATION_SUFFIX}") + val normalized = name.normalizedSidecarName() + normalized.matchesJsonSidecarStem(hashedStem) || + normalized.matchesJsonSidecarStem(bookId) || + normalized.matchesJsonSidecarStem(hashedAnnotationStem) || + normalized.matchesJsonSidecarStem("$bookId$ANNOTATION_SUFFIX") } targets.forEach { try { @@ -626,34 +646,32 @@ object LocalSyncUtils { try { val allFiles = querySyncSubfolderFiles(context, sourceFolderUri) - val groupedFiles = allFiles - .filter { - val name = it.name - (name.endsWith(".json") || name.contains(".sync-conflict")) && - !name.contains(ANNOTATION_SUFFIX) && - !name.endsWith(".tmp") && - !name.contains(".syncthing.") - } - .groupBy { file -> - var name = file.name - if (name.startsWith(".")) name = name.substring(1) - if (name.contains(".sync-conflict")) { - name.substringBefore(".sync-conflict") - } else { - name.substringBefore(".json") + val groupedMetadata = allFiles + .asSequence() + .filter { isMetadataSidecarCandidateName(it.name) } + .mapNotNull { file -> + try { + val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input -> + input.bufferedReader().use { it.readText() } + } + jsonString?.let(FolderBookMetadata::fromJsonString) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}") + null } } + .groupBy { it.bookId } - groupedFiles.forEach { (bookId, files) -> - val winner = resolveMetadataConflictsReadOnly(context, files, bookId) + groupedMetadata.forEach { (bookId, metadataRecords) -> + val winner = metadataRecords.maxByOrNull { it.lastModifiedTimestamp } if (winner != null) { finalResults[bookId] = winner } } - Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.") + Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedMetadata.size} book records from sync data.") ReaderPerfLog.d( - "LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}" + "LocalSync metadata read files=${allFiles.size} groups=${groupedMetadata.size} records=${finalResults.size}" ) } catch (e: Exception) { @@ -663,29 +681,4 @@ object LocalSyncUtils { return@withContext finalResults } - private fun resolveMetadataConflictsReadOnly( - context: Context, - files: List, - bookId: String - ): FolderBookMetadata? { - var bestMeta: FolderBookMetadata? = null - for (file in files) { - try { - val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input -> - input.bufferedReader().use { it.readText() } - } - if (jsonString != null) { - val meta = FolderBookMetadata.fromJsonString(jsonString) - if (meta.bookId == bookId && - (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) - ) { - bestMeta = meta - } - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}") - } - } - return bestMeta - } } diff --git a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt b/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt index 607bb45..a3e41b6 100644 --- a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt +++ b/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt @@ -28,7 +28,8 @@ data class PurchaseEntity( val purchaseToken: String, val purchaseTime: Long, val isAcknowledged: Boolean, - val isAutoRenewing: Boolean + val isAutoRenewing: Boolean, + val obfuscatedAccountId: String? = null ) /** @@ -41,4 +42,4 @@ data class ProductDetailsEntity( val formattedPrice: String, val currencyCode: String, val priceAmountMicros: Long -) \ 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 1db6c5a..08cc5fb 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -33,7 +33,7 @@ interface RecentFileDao { @Upsert suspend fun insertOrUpdateFiles(files: List) - @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") fun getRecentFiles(): Flow> @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @@ -45,7 +45,7 @@ interface RecentFileDao { @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) - @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") fun getRecentFilesList(limit: Int): List @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @@ -94,26 +94,36 @@ interface RecentFileDao { SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND isDeleted = 0 - AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') - AND folderTextMetadataParsed = 0 + AND ( + (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0) + OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = '')) + ) + ORDER BY timestamp DESC + LIMIT :limit """) - suspend fun getFolderBooksNeedingTextMetadata(): List + suspend fun getFolderBooksNeedingTextMetadata(limit: Int): List @Query(""" SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0 - AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') - AND folderTextMetadataParsed = 0 + AND ( + (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0) + OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = '')) + ) + ORDER BY timestamp DESC + LIMIT :limit """) - suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List + suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String, limit: Int): List @Query(""" SELECT COUNT(*) FROM recent_files WHERE sourceFolderUri IS NOT NULL AND isDeleted = 0 - AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') - AND folderTextMetadataParsed = 0 + AND ( + (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0) + OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = '')) + ) """) suspend fun countFolderBooksNeedingTextMetadata(): Int @@ -121,8 +131,10 @@ interface RecentFileDao { SELECT COUNT(*) FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0 - AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') - AND folderTextMetadataParsed = 0 + AND ( + (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0) + OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = '')) + ) """) suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int @@ -130,10 +142,60 @@ interface RecentFileDao { UPDATE recent_files SET coverImagePath = COALESCE(:coverImagePath, coverImagePath), - title = COALESCE(:title, title), - author = COALESCE(:author, author), + title = CASE + WHEN :title IS NOT NULL AND (originalTitle IS NULL OR title IS NULL OR title = originalTitle OR title = displayName) + THEN :title + ELSE title + END, + author = CASE + WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR author IS NULL OR author = originalAuthor) + THEN :author + ELSE author + END, + seriesName = CASE + WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR seriesName IS NULL OR seriesName = originalSeriesName) + THEN :seriesName + ELSE seriesName + END, + seriesIndex = CASE + WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR seriesIndex IS NULL OR seriesIndex = originalSeriesIndex) + THEN :seriesIndex + ELSE seriesIndex + END, + description = CASE + WHEN :description IS NOT NULL AND (originalDescription IS NULL OR description IS NULL OR description = originalDescription) + THEN :description + ELSE description + END, + originalTitle = CASE + WHEN :title IS NOT NULL AND (originalTitle IS NULL OR originalTitle = title OR originalTitle = displayName) + THEN :title + ELSE originalTitle + END, + originalAuthor = CASE + WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR originalAuthor = author) + THEN :author + ELSE originalAuthor + END, + originalSeriesName = CASE + WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR originalSeriesName = seriesName) + THEN :seriesName + ELSE originalSeriesName + END, + originalSeriesIndex = CASE + WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR originalSeriesIndex = seriesIndex) + THEN :seriesIndex + ELSE originalSeriesIndex + END, + originalDescription = CASE + WHEN :description IS NOT NULL AND (originalDescription IS NULL OR originalDescription = description) + THEN :description + ELSE originalDescription + END, fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END, - folderTextMetadataParsed = 1 + fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END, + folderTextMetadataParsed = CASE WHEN :textMetadataParsed = 1 THEN 1 ELSE folderTextMetadataParsed END, + folderCoverMetadataParsed = CASE WHEN :coverMetadataParsed = 1 THEN 1 ELSE folderCoverMetadataParsed END WHERE bookId = :bookId """) suspend fun updateExtractedMetadata( @@ -141,7 +203,13 @@ interface RecentFileDao { coverImagePath: String?, title: String?, author: String?, - fileSize: Long + seriesName: String?, + seriesIndex: Double?, + description: String?, + fileSize: Long, + fileContentModifiedTimestamp: Long, + textMetadataParsed: Boolean, + coverMetadataParsed: Boolean ) @Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL") @@ -149,4 +217,58 @@ interface RecentFileDao { @Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long) + + @Query(""" + UPDATE recent_files + SET + title = :title, + author = :author, + seriesName = :seriesName, + seriesIndex = :seriesIndex, + description = :description, + customName = NULL, + originalTitle = COALESCE(originalTitle, title), + originalAuthor = COALESCE(originalAuthor, author), + originalSeriesName = COALESCE(originalSeriesName, seriesName), + originalSeriesIndex = COALESCE(originalSeriesIndex, seriesIndex), + originalDescription = COALESCE(originalDescription, description), + fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END, + fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END, + folderTextMetadataParsed = 1, + lastModifiedTimestamp = :timestamp + WHERE bookId = :bookId + """) + suspend fun updateUserEditableMetadata( + bookId: String, + title: String?, + author: String?, + seriesName: String?, + seriesIndex: Double?, + description: String?, + fileSize: Long, + fileContentModifiedTimestamp: Long, + timestamp: Long + ) + + @Query(""" + UPDATE recent_files + SET + title = COALESCE(originalTitle, displayName), + author = originalAuthor, + seriesName = originalSeriesName, + seriesIndex = originalSeriesIndex, + description = originalDescription, + customName = NULL, + fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END, + fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END, + folderTextMetadataParsed = 1, + lastModifiedTimestamp = :timestamp + WHERE bookId = :bookId + """) + suspend fun restoreOriginalMetadata( + bookId: String, + fileSize: Long, + fileContentModifiedTimestamp: Long, + timestamp: Long + ) } 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 4dbbb0b..26ace97 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -52,10 +52,17 @@ data class RecentFileEntity( @ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, + @ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L, @ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, @ColumnInfo(defaultValue = "NULL") val description: String?, - @ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean + @ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean, + @ColumnInfo(defaultValue = "0") val folderCoverMetadataParsed: Boolean = false, + @ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null, + @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null ) data class RecentFileSummary( @@ -81,7 +88,13 @@ data class RecentFileSummary( @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, + @ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L, @ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, - @ColumnInfo(defaultValue = "NULL") val description: String? + @ColumnInfo(defaultValue = "NULL") val description: String?, + @ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null, + @ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null, + @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null ) 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 4eb094a..6276f79 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -46,10 +46,17 @@ data class RecentFileItem( val customName: String? = null, val highlightsJson: String? = null, val fileSize: Long = 0L, + val fileContentModifiedTimestamp: Long = 0L, val seriesName: String? = null, val seriesIndex: Double? = null, val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null, val folderTextMetadataParsed: Boolean = false, + val folderCoverMetadataParsed: Boolean = false, val tags: List = emptyList() ) @@ -79,10 +86,17 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { customName = this.customName, highlightsJson = this.highlights, fileSize = this.fileSize, + fileContentModifiedTimestamp = this.fileContentModifiedTimestamp, seriesName = this.seriesName, seriesIndex = this.seriesIndex, description = this.description, - folderTextMetadataParsed = this.folderTextMetadataParsed + originalTitle = this.originalTitle, + originalAuthor = this.originalAuthor, + originalSeriesName = this.originalSeriesName, + originalSeriesIndex = this.originalSeriesIndex, + originalDescription = this.originalDescription, + folderTextMetadataParsed = this.folderTextMetadataParsed, + folderCoverMetadataParsed = this.folderCoverMetadataParsed ) } @@ -112,10 +126,17 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { customName = this.customName, highlights = this.highlightsJson, fileSize = this.fileSize, + fileContentModifiedTimestamp = this.fileContentModifiedTimestamp, seriesName = this.seriesName, seriesIndex = this.seriesIndex, description = this.description, - folderTextMetadataParsed = this.folderTextMetadataParsed + originalTitle = this.originalTitle ?: this.title, + originalAuthor = this.originalAuthor ?: this.author, + originalSeriesName = this.originalSeriesName ?: this.seriesName, + originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex, + originalDescription = this.originalDescription ?: this.description, + folderTextMetadataParsed = this.folderTextMetadataParsed, + folderCoverMetadataParsed = this.folderCoverMetadataParsed ) } @@ -138,7 +159,16 @@ fun RecentFileItem.toBookMetadata(): BookMetadata { bookmarksJson = this.bookmarksJson, hasAnnotations = false, customName = this.customName, - highlightsJson = this.highlightsJson + highlightsJson = this.highlightsJson, + fileContentModifiedTimestamp = this.fileContentModifiedTimestamp, + seriesName = this.seriesName, + seriesIndex = this.seriesIndex, + description = this.description, + originalTitle = this.originalTitle ?: this.title, + originalAuthor = this.originalAuthor ?: this.author, + originalSeriesName = this.originalSeriesName ?: this.seriesName, + originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex, + originalDescription = this.originalDescription ?: this.description ) } @@ -164,7 +194,16 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem { isDeleted = this.isDeleted, bookmarksJson = this.bookmarksJson, customName = this.customName, - highlightsJson = this.highlightsJson + highlightsJson = this.highlightsJson, + fileContentModifiedTimestamp = this.fileContentModifiedTimestamp, + seriesName = this.seriesName, + seriesIndex = this.seriesIndex, + description = this.description, + originalTitle = this.originalTitle, + originalAuthor = this.originalAuthor, + originalSeriesName = this.originalSeriesName, + originalSeriesIndex = this.originalSeriesIndex, + originalDescription = this.originalDescription ) } @@ -194,8 +233,14 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem { customName = this.customName, highlightsJson = null, fileSize = this.fileSize, + fileContentModifiedTimestamp = this.fileContentModifiedTimestamp, seriesName = this.seriesName, seriesIndex = this.seriesIndex, - description = this.description + description = this.description, + originalTitle = this.originalTitle, + originalAuthor = this.originalAuthor, + originalSeriesName = this.originalSeriesName, + originalSeriesIndex = this.originalSeriesIndex, + originalDescription = this.originalDescription ) } 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 b18ef03..673485e 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -22,9 +22,12 @@ package com.aryan.reader.data import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import androidx.core.net.toUri +import com.aryan.reader.FileType import com.aryan.reader.ReaderPerfLog +import com.aryan.reader.scaledToCanvasLimit import timber.log.Timber import com.aryan.reader.BookImporter import com.aryan.reader.paginatedreader.Locator @@ -40,7 +43,6 @@ import java.io.FileOutputStream import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfTextBoxRepository -import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import org.json.JSONObject import org.json.JSONArray @@ -48,6 +50,9 @@ import java.util.UUID import androidx.core.content.edit private const val COVER_CACHE_DIR = "cover_cache" +private const val DIRECT_EMBEDDED_COVER_MAX_BYTES = 8L * 1024L * 1024L +private const val EMBEDDED_COVER_MAX_DIMENSION = 1200 +private val EMBEDDED_COVER_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") class RecentFilesRepository(private val context: Context) { @@ -169,12 +174,43 @@ class RecentFilesRepository(private val context: Context) { } val entityToInsert = if (existingItem != null) { + val folderFileChanged = item.sourceFolderUri != null && + existingItem.sourceFolderUri == item.sourceFolderUri && + ((item.fileSize > 0L && item.fileSize != existingItem.fileSize) || + (item.fileContentModifiedTimestamp > 0L && + item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp)) + val embeddedMetadataFileChanged = + (item.fileSize > 0L && existingItem.fileSize > 0L && item.fileSize != existingItem.fileSize) || + (item.fileContentModifiedTimestamp > 0L && + existingItem.fileContentModifiedTimestamp > 0L && + item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp) + val keepExistingEmbeddedMetadata = item.type == FileType.EPUB && + existingItem.type == FileType.EPUB && + !embeddedMetadataFileChanged && + existingItem.hasEmbeddedMetadataChanges() + item.toRecentFileEntity().copy( uriString = existingItem.uriString ?: item.uriString, isAvailable = existingItem.isAvailable || item.isAvailable, - coverImagePath = item.coverImagePath ?: existingItem.coverImagePath, - title = item.title ?: existingItem.title, - author = item.author ?: existingItem.author, + coverImagePath = if (folderFileChanged) { + item.coverImagePath + } else { + item.coverImagePath ?: existingItem.coverImagePath + }, + title = if (folderFileChanged) { + item.title ?: item.displayName.substringBeforeLast('.', item.displayName) + } else if (keepExistingEmbeddedMetadata) { + existingItem.title + } else { + item.title ?: existingItem.title + }, + author = if (folderFileChanged) { + item.author + } else if (keepExistingEmbeddedMetadata) { + existingItem.author + } else { + item.author ?: existingItem.author + }, lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex, lastPage = item.lastPage ?: existingItem.lastPage, lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi, @@ -187,10 +223,43 @@ class RecentFilesRepository(private val context: Context) { sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, highlights = item.highlightsJson ?: existingItem.highlights, fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize, - seriesName = item.seriesName ?: existingItem.seriesName, - seriesIndex = item.seriesIndex ?: existingItem.seriesIndex, - description = item.description ?: existingItem.description, - folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed + fileContentModifiedTimestamp = if (item.fileContentModifiedTimestamp > 0) item.fileContentModifiedTimestamp else existingItem.fileContentModifiedTimestamp, + seriesName = if (folderFileChanged) { + item.seriesName + } else if (keepExistingEmbeddedMetadata) { + existingItem.seriesName + } else { + item.seriesName ?: existingItem.seriesName + }, + seriesIndex = if (folderFileChanged) { + item.seriesIndex + } else if (keepExistingEmbeddedMetadata) { + existingItem.seriesIndex + } else { + item.seriesIndex ?: existingItem.seriesIndex + }, + description = if (folderFileChanged) { + item.description + } else if (keepExistingEmbeddedMetadata) { + existingItem.description + } else { + item.description ?: existingItem.description + }, + originalTitle = if (folderFileChanged) item.originalTitle ?: item.title else existingItem.originalTitle ?: item.originalTitle ?: item.title, + originalAuthor = if (folderFileChanged) item.originalAuthor ?: item.author else existingItem.originalAuthor ?: item.originalAuthor ?: item.author, + originalSeriesName = if (folderFileChanged) item.originalSeriesName ?: item.seriesName else existingItem.originalSeriesName ?: item.originalSeriesName ?: item.seriesName, + originalSeriesIndex = if (folderFileChanged) item.originalSeriesIndex ?: item.seriesIndex else existingItem.originalSeriesIndex ?: item.originalSeriesIndex ?: item.seriesIndex, + originalDescription = if (folderFileChanged) item.originalDescription ?: item.description else existingItem.originalDescription ?: item.originalDescription ?: item.description, + folderTextMetadataParsed = if (folderFileChanged) { + item.folderTextMetadataParsed + } else { + item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed + }, + folderCoverMetadataParsed = if (folderFileChanged) { + item.folderCoverMetadataParsed + } else { + item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed + } ) } else { item.toRecentFileEntity() @@ -201,13 +270,60 @@ class RecentFilesRepository(private val context: Context) { Timber.d("Added/Updated recent file in DB: ${item.displayName}") } + private fun RecentFileEntity.hasEmbeddedMetadataChanges(): Boolean { + val hasOriginalMetadata = listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription) + .any { !it.isNullOrBlank() } || originalSeriesIndex != null + return (hasOriginalMetadata && ( + metadataValueChanged(title, originalTitle) || + metadataValueChanged(author, originalAuthor) || + metadataValueChanged(seriesName, originalSeriesName) || + seriesIndex != originalSeriesIndex || + metadataValueChanged(description, originalDescription) + )) + } + + private fun metadataValueChanged(current: String?, original: String?): Boolean { + return current.orEmpty().trim() != original.orEmpty().trim() + } + + suspend fun updateUserEditableMetadata( + bookId: String, + metadata: BookMetadataEdit, + fileSize: Long = 0L, + fileContentModifiedTimestamp: Long = 0L + ) = withContext(Dispatchers.IO) { + val currentTime = System.currentTimeMillis() + recentFileDao.updateUserEditableMetadata( + bookId = bookId, + title = metadata.title, + author = metadata.author, + seriesName = metadata.seriesName, + seriesIndex = metadata.seriesIndex, + description = metadata.description, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + timestamp = currentTime + ) + Timber.d("Updated user-editable metadata for $bookId") + } + + suspend fun restoreOriginalMetadata( + bookId: String, + fileSize: Long = 0L, + fileContentModifiedTimestamp: Long = 0L + ) = withContext(Dispatchers.IO) { + val currentTime = System.currentTimeMillis() + recentFileDao.restoreOriginalMetadata(bookId, fileSize, fileContentModifiedTimestamp, currentTime) + Timber.d("Restored original metadata for $bookId") + } + 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) { + suspend fun syncLocalMetadataToFolder(bookId: String, force: Boolean = false) = withContext(Dispatchers.IO) { val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext val folderUriString = entity.sourceFolderUri @@ -217,7 +333,7 @@ class RecentFilesRepository(private val context: Context) { val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]" val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights - if (!isDirty) { + if (!force && !isDirty) { Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.") return@withContext } @@ -240,7 +356,15 @@ class RecentFilesRepository(private val context: Context) { locatorBlockIndex = entity.locatorBlockIndex, locatorCharOffset = entity.locatorCharOffset, customName = entity.customName, - highlightsJson = entity.highlights + highlightsJson = entity.highlights, + seriesName = entity.seriesName, + seriesIndex = entity.seriesIndex, + description = entity.description, + originalTitle = entity.originalTitle, + originalAuthor = entity.originalAuthor, + originalSeriesName = entity.originalSeriesName, + originalSeriesIndex = entity.originalSeriesIndex, + originalDescription = entity.originalDescription ) LocalSyncUtils.saveMetadataToFolder( @@ -275,7 +399,7 @@ class RecentFilesRepository(private val context: Context) { val hasHighlights = highlightFile.exists() Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights") - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.folder.export candidates book=$bookId hasRichText=$hasRichText " + "richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString" ) @@ -291,7 +415,7 @@ class RecentFilesRepository(private val context: Context) { try { val content = file.readText().trim() if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" ) } @@ -302,7 +426,7 @@ class RecentFilesRepository(private val context: Context) { } } catch (e: Exception) { if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + Timber .e(e, "android.folder.export.richTextParseFailed book=$bookId") } Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file") @@ -328,7 +452,7 @@ class RecentFilesRepository(private val context: Context) { val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) if (hasRichText) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}" ) } @@ -348,7 +472,7 @@ class RecentFilesRepository(private val context: Context) { val bundle = JSONObject( SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) ) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " + "hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" ) @@ -359,7 +483,7 @@ class RecentFilesRepository(private val context: Context) { val contentStr = bundle.get(key).toString() file.writeText(contentStr) if (key == "text") { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}" ) } @@ -431,11 +555,15 @@ class RecentFilesRepository(private val context: Context) { } } - suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List = withContext(Dispatchers.IO) { + suspend fun getFolderBooksNeedingTextMetadata( + sourceFolderUri: String? = null, + limit: Int = Int.MAX_VALUE + ): List = withContext(Dispatchers.IO) { + val queryLimit = limit.coerceAtLeast(1) val entities = if (sourceFolderUri.isNullOrBlank()) { - recentFileDao.getFolderBooksNeedingTextMetadata() + recentFileDao.getFolderBooksNeedingTextMetadata(queryLimit) } else { - recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri) + recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri, queryLimit) } return@withContext entities.map { it.toRecentFileItem() } } @@ -459,7 +587,13 @@ class RecentFilesRepository(private val context: Context) { coverImagePath = item.coverImagePath, title = item.title, author = item.author, - fileSize = item.fileSize + seriesName = item.seriesName, + seriesIndex = item.seriesIndex, + description = item.description, + fileSize = item.fileSize, + fileContentModifiedTimestamp = item.fileContentModifiedTimestamp, + textMetadataParsed = item.folderTextMetadataParsed, + coverMetadataParsed = item.folderCoverMetadataParsed ) } } @@ -562,9 +696,17 @@ class RecentFilesRepository(private val context: Context) { val filename = "cover_${uri.toString().hashCode()}.png" val file = File(cacheDir, filename) var fos: FileOutputStream? = null + var scaledCopy: Bitmap? = null try { + deleteCoverCacheVariants(uri) + val bitmapToSave = bitmap.scaledToCanvasLimit( + maxBytes = 8L * 1024L * 1024L, + maxDimension = EMBEDDED_COVER_MAX_DIMENSION + ).also { + if (it !== bitmap) scaledCopy = it + } fos = FileOutputStream(file) - bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos) + bitmapToSave.compress(Bitmap.CompressFormat.PNG, 90, fos) Timber.d("Saved cover image to: ${file.absolutePath}") return@withContext file.absolutePath } catch (e: Exception) { @@ -573,9 +715,70 @@ class RecentFilesRepository(private val context: Context) { return@withContext null } finally { fos?.close() + scaledCopy?.recycle() } } + suspend fun saveEmbeddedCoverToCache(bytes: ByteArray, uri: Uri, extension: String): String? = withContext(Dispatchers.IO) { + if (bytes.isEmpty()) return@withContext null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null + + val safeExtension = extension.lowercase().takeIf { it in EMBEDDED_COVER_EXTENSIONS } ?: "png" + if ( + bytes.size.toLong() <= DIRECT_EMBEDDED_COVER_MAX_BYTES && + bounds.outWidth <= EMBEDDED_COVER_MAX_DIMENSION && + bounds.outHeight <= EMBEDDED_COVER_MAX_DIMENSION + ) { + return@withContext saveEmbeddedCoverBytesToCache(bytes, uri, safeExtension) + } + + var sampleSize = 1 + while ( + (bounds.outWidth / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION || + (bounds.outHeight / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION + ) { + sampleSize *= 2 + } + + val decoded = BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { inSampleSize = sampleSize } + ) ?: return@withContext null + + try { + return@withContext saveCoverToCache(decoded, uri) + } finally { + decoded.recycle() + } + } + + private fun saveEmbeddedCoverBytesToCache(bytes: ByteArray, uri: Uri, extension: String): String? { + val cacheDir = getCoverCacheDirInternal() + val filename = "cover_${uri.toString().hashCode()}.$extension" + val file = File(cacheDir, filename) + return try { + deleteCoverCacheVariants(uri) + FileOutputStream(file).use { output -> output.write(bytes) } + Timber.d("Saved embedded cover image to: ${file.absolutePath}") + file.absolutePath + } catch (e: Exception) { + Timber.e(e, "Failed to save embedded cover image to cache for $uri") + file.delete() + null + } + } + + private fun deleteCoverCacheVariants(uri: Uri) { + val prefix = "cover_${uri.toString().hashCode()}." + getCoverCacheDirInternal().listFiles() + ?.filter { it.isFile && it.name.startsWith(prefix) } + ?.forEach { runCatching { it.delete() } } + } + private fun deleteCachedCover(filePath: String): Boolean { val file = File(filePath) val deleted = file.delete() @@ -622,10 +825,11 @@ class RecentFilesRepository(private val context: Context) { suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) { try { + recentFileDao.getFileByBookId(bookId)?.coverImagePath?.let { deleteCachedCover(it) } pdfRichTextRepository.getFileForSync(bookId).delete() pageLayoutRepository.getLayoutFile(bookId).delete() ImportedFileCache.clearBookCache(context, bookId) - Timber.d("Cleared layout and text caches for modified book: $bookId") + Timber.d("Cleared local caches for modified book: $bookId") } catch (e: Exception) { Timber.e(e, "Error clearing caches for $bookId") } diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index e8649d2..716fba2 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -35,9 +35,11 @@ import org.w3c.dom.Node import java.io.File import java.io.FileOutputStream import java.io.InputStream +import java.io.ByteArrayOutputStream import java.net.URLDecoder import java.nio.file.Paths import java.util.UUID +import java.util.zip.ZipEntry import java.util.zip.ZipFile import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -78,7 +80,8 @@ class EpubParser(private val context: Context) { val originalBookNameHint: String, val parserVersion: Int, val parseContent: Boolean, - val shouldUseToc: Boolean + val shouldUseToc: Boolean, + val sourceFingerprint: String? = null ) // EpubFile can still represent in-memory file data during initial parsing before extraction @@ -109,6 +112,9 @@ class EpubParser(private val context: Context) { private const val BOOK_METADATA_FILE = "book_metadata.json" private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json" private const val EPUB_EXTRACTION_CACHE_VERSION = 1 + private const val MAX_METADATA_ENTRY_BYTES = 4 * 1024 * 1024 + private const val EPUB_COVER_MAX_DIMENSION = 1024 + private val EPUB_IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg") } internal val String.decodedURL: String @@ -185,7 +191,8 @@ class EpubParser(private val context: Context) { shouldUseToc: Boolean = true, originalBookNameHint: String = "streamed_book", parseContent: Boolean = true, - extractionDirOverride: File? = null + extractionDirOverride: File? = null, + sourceFingerprint: String? = null ): EpubBook { return withContext(Dispatchers.IO) { Timber.d("Parsing EPUB input stream for bookId: $bookId") @@ -201,7 +208,8 @@ class EpubParser(private val context: Context) { extractionDir = activeDir, bookId = bookId, originalBookNameHint = originalBookNameHint, - shouldUseToc = shouldUseToc + shouldUseToc = shouldUseToc, + sourceFingerprint = sourceFingerprint )?.let { cachedBook -> Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId") return@withContext cachedBook @@ -215,7 +223,12 @@ class EpubParser(private val context: Context) { tempFile.outputStream().use { output -> inputStream.copyTo(output) } - filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent) + filesMap = extractEpubContents( + zipFile = ZipFile(tempFile), + extractionDir = extractionDir, + parseContent = parseContent, + extractImagesForMetadata = shouldDeleteExtractionDir + ) } finally { tempFile.delete() } @@ -229,6 +242,7 @@ class EpubParser(private val context: Context) { bookId = bookId, originalBookNameHint = originalBookNameHint, shouldUseToc = shouldUseToc, + sourceFingerprint = sourceFingerprint, book = book ) } @@ -243,7 +257,8 @@ class EpubParser(private val context: Context) { extractionDir: File, bookId: String, originalBookNameHint: String, - shouldUseToc: Boolean + shouldUseToc: Boolean, + sourceFingerprint: String? ): EpubBook? { val metadataFile = File(extractionDir, BOOK_METADATA_FILE) val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE) @@ -255,7 +270,8 @@ class EpubParser(private val context: Context) { manifest.originalBookNameHint == originalBookNameHint && manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION && manifest.parseContent && - manifest.shouldUseToc == shouldUseToc + manifest.shouldUseToc == shouldUseToc && + manifest.sourceFingerprint == sourceFingerprint if (!isCompatible) { Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId") @@ -277,6 +293,7 @@ class EpubParser(private val context: Context) { bookId: String, originalBookNameHint: String, shouldUseToc: Boolean, + sourceFingerprint: String?, book: EpubBook ) { try { @@ -288,7 +305,8 @@ class EpubParser(private val context: Context) { originalBookNameHint = originalBookNameHint, parserVersion = EPUB_EXTRACTION_CACHE_VERSION, parseContent = true, - shouldUseToc = shouldUseToc + shouldUseToc = shouldUseToc, + sourceFingerprint = sourceFingerprint ) ) ) @@ -297,22 +315,40 @@ class EpubParser(private val context: Context) { } } - private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map { + internal fun extractEpubContents( + zipFile: ZipFile, + extractionDir: File, + parseContent: Boolean, + extractImagesForMetadata: Boolean + ): Map { val filesMap = mutableMapOf() zipFile.use { zf -> zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry -> val isEssential = isEssentialFile(entry.name, parseContent) - val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE)) + val isImage = isEpubImageFile(entry.name) if (!parseContent) { - if (isEssential || isImage) { - val data = zf.getInputStream(entry).readBytes() - filesMap[entry.name] = EpubFile(absPath = entry.name, data = data) + when { + isEssential -> { + val data = zf.readSmallEntryBytes(entry) ?: return@forEach + filesMap[entry.name] = EpubFile(absPath = entry.name, data = data) + } + isImage && extractImagesForMetadata -> { + val outputFile = safeExtractionFile(extractionDir, entry.name) + ?: return@forEach + outputFile.parentFile?.mkdirs() + zf.getInputStream(entry).use { input -> + FileOutputStream(outputFile).use { output -> + input.copyTo(output) + } + } + filesMap[entry.name] = EpubFile(absPath = entry.name, data = ByteArray(0)) + } } return@forEach } - val outputFile = File(extractionDir, entry.name) + val outputFile = safeExtractionFile(extractionDir, entry.name) ?: return@forEach outputFile.parentFile?.mkdirs() zf.getInputStream(entry).use { input -> FileOutputStream(outputFile).use { output -> @@ -329,6 +365,59 @@ class EpubParser(private val context: Context) { return filesMap } + private fun isEpubImageFile(fileName: String): Boolean { + val lowerName = fileName.lowercase() + return EPUB_IMAGE_EXTENSIONS.any { lowerName.endsWith(it) } + } + + private fun safeExtractionFile(extractionDir: File, entryName: String): File? { + val outputFile = File(extractionDir, entryName) + val root = extractionDir.canonicalFile + val target = outputFile.canonicalFile + val rootPath = root.path + val targetPath = target.path + val isInsideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) + + if (!isInsideRoot) { + Timber.w("Skipping unsafe EPUB entry outside extraction root: $entryName") + return null + } + + return outputFile + } + + private fun ZipFile.readSmallEntryBytes(entry: ZipEntry): ByteArray? { + if (entry.size > MAX_METADATA_ENTRY_BYTES.toLong()) { + Timber.w("Skipping oversized EPUB metadata entry: ${entry.name} (${entry.size} bytes)") + return null + } + + val initialSize = entry.size + .takeIf { it in 0..MAX_METADATA_ENTRY_BYTES.toLong() } + ?.toInt() + ?: DEFAULT_BUFFER_SIZE + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalBytes = 0 + + return getInputStream(entry).use { input -> + ByteArrayOutputStream(initialSize).use { output -> + while (true) { + val read = input.read(buffer) + if (read == -1) break + + totalBytes += read + if (totalBytes > MAX_METADATA_ENTRY_BYTES) { + Timber.w("Skipping oversized EPUB metadata entry while reading: ${entry.name}") + return null + } + + output.write(buffer, 0, read) + } + output.toByteArray() + } + } + } + private suspend fun parseAndCreateEbook( filesContentMap: Map, document: EpubDocument, @@ -712,8 +801,6 @@ class EpubParser(private val context: Context) { filesContentMap: Map, @Suppress("UNUSED_PARAMETER") extractionRoot: File ): List { - val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg") - val listedImages = manifestItems.values .filter { it.mediaType.startsWith("image/") } .map { manifestItem -> @@ -725,7 +812,7 @@ class EpubParser(private val context: Context) { val unlistedImages = filesContentMap.keys .filter { path -> val lowerPath = path.lowercase() - imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path) + EPUB_IMAGE_EXTENSIONS.any { lowerPath.endsWith(it) } && !listedPaths.contains(path) } .map { path -> EpubImage(absPath = path) @@ -743,11 +830,9 @@ class EpubParser(private val context: Context) { ): Bitmap? { val coverManifestItem = manifestItems[metadataCoverId] if (coverManifestItem != null) { - val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() } - ?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes() - - if (coverImageBytes != null) { - return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size) + val coverImage = decodeEpubImage(coverManifestItem.absPath, filesContentMap, extractionRoot) + if (coverImage != null) { + return coverImage } else { Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}") } @@ -768,21 +853,15 @@ class EpubParser(private val context: Context) { ) for (path in possiblePaths) { if (filesContentMap.containsKey(path)) { - val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() } - ?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes() - - bytes?.let { + decodeEpubImage(path, filesContentMap, extractionRoot)?.let { Timber.d("Found fallback cover image at $path") - return BitmapFactory.decodeByteArray(it, 0, it.size) + return it } } manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem -> - val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() } - ?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes() - - bytes?.let { + decodeEpubImage(manifestItem.absPath, filesContentMap, extractionRoot)?.let { Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}") - return BitmapFactory.decodeByteArray(it, 0, it.size) + return it } } } @@ -791,6 +870,53 @@ class EpubParser(private val context: Context) { return null } + private fun decodeEpubImage( + path: String, + filesContentMap: Map, + extractionRoot: File + ): Bitmap? { + filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }?.let { bytes -> + return decodeSampledByteArray(bytes) + } + + val imageFile = File(extractionRoot, path).takeIf { it.exists() && it.isFile } ?: return null + return decodeSampledFile(imageFile) + } + + private fun decodeSampledByteArray(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + val options = BitmapFactory.Options().apply { + inSampleSize = calculateBitmapSampleSize(bounds) + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + + private fun decodeSampledFile(file: File): Bitmap? { + val bounds = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, bounds) + val options = BitmapFactory.Options().apply { + inSampleSize = calculateBitmapSampleSize(bounds) + } + return BitmapFactory.decodeFile(file.absolutePath, options) + } + + private fun calculateBitmapSampleSize(options: BitmapFactory.Options): Int { + val width = options.outWidth + val height = options.outHeight + if (width <= 0 || height <= 0) return 1 + + var sampleSize = 1 + while ((width / sampleSize) > EPUB_COVER_MAX_DIMENSION || (height / sampleSize) > EPUB_COVER_MAX_DIMENSION) { + sampleSize *= 2 + } + return sampleSize + } + private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean { val lowerName = fileName.lowercase() diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 714749a..9ea4bb9 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -29,6 +29,7 @@ import android.content.Intent import android.graphics.Color import android.graphics.Rect import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView @@ -90,6 +91,34 @@ import java.io.BufferedReader import java.io.InputStreamReader private const val TAG_LINK_NAV = "LINK_NAV" +private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( + "PageInfoReporter", + "ProgressReporter", + "ContentBridge", + "HighlightBridge", + "AutoScrollBridge", + "CfiBridge", + "SnippetBridge", + "TtsBridge", + "AiBridge", + "FootnoteBridge", + "LinkNavBridge" +) + +private fun WebView.releaseReaderResources() { + try { + stopLoading() + READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) } + webChromeClient = null + webViewClient = WebViewClient() + loadDataWithBaseURL(null, "", "text/html", "UTF-8", null) + clearHistory() + removeAllViews() + destroy() + } catch (e: Exception) { + Timber.w(e, "Failed to fully release EPUB WebView resources") + } +} private fun getFontCssInjection(): String { return """ @@ -314,12 +343,14 @@ class FootnoteJsBridge( @Suppress("unused") class LinkNavJsBridge( - private val currentChapterTitle: String + private val currentChapterTitle: String, + private val onInternalLinkClick: (String) -> Unit ) { @JavascriptInterface fun onLinkClicked(href: String, epubType: String, linkText: String) { Timber.tag(TAG_LINK_NAV) .d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'") + onInternalLinkClick(href) } } @@ -384,6 +415,7 @@ fun ChapterWebView( activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, onInternalLinkClick: (String) -> Unit, + onWebViewDisposed: (WebView) -> Unit = {}, activeTextureId: String? = null, activeTextureAlpha: Float = 0.55f ) { @@ -554,7 +586,7 @@ fun ChapterWebView( }, "AutoScrollBridge" ) - webChromeClient = object : android.webkit.WebChromeClient() { + webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { consoleMessage?.let { val message = it.message() @@ -668,7 +700,9 @@ fun ChapterWebView( ) addJavascriptInterface( - LinkNavJsBridge(chapterTitle), "LinkNavBridge" + LinkNavJsBridge(chapterTitle) { href -> + this.post { onInternalLinkClick(href) } + }, "LinkNavBridge" ) webViewClient = object : WebViewClient() { @@ -744,6 +778,37 @@ fun ChapterWebView( null ) + view?.evaluateJavascript( + """ + javascript:(function() { + if (window.__readerInternalLinkBridgeInstalled) return; + window.__readerInternalLinkBridgeInstalled = true; + document.addEventListener('click', function(event) { + var target = event.target; + var anchor = target && target.closest ? target.closest('a[href]') : null; + if (!anchor && target && target.parentElement && target.parentElement.closest) { + anchor = target.parentElement.closest('a[href]'); + } + if (!anchor) return; + var rawHref = anchor.getAttribute('href') || ''; + if (!rawHref) return; + if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return; + if (/^\/\//.test(rawHref)) return; + event.preventDefault(); + var resolvedHref = anchor.href || rawHref; + if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) { + window.LinkNavBridge.onLinkClicked( + resolvedHref, + anchor.getAttribute('epub:type') || '', + anchor.textContent || '' + ); + } + }, true); + })(); + """.trimIndent(), + null + ) + view?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.restoreHighlights('${ escapeJsString( @@ -907,10 +972,19 @@ fun ChapterWebView( loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null) } webView - }, update = { webView -> - Timber.d( - "WebView update. Setting Font: ${currentFontFamily.fontFamilyName}" - ) + }, + modifier = Modifier.fillMaxSize(), + onRelease = { releasedWebView -> + if (localWebViewRef === releasedWebView) { + localWebViewRef = null + } + customMenuState?.finishActionModeCallback?.invoke() + customMenuState = null + onWebViewDisposed(releasedWebView) + releasedWebView.releaseReaderResources() + }, + update = { webView -> + Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}") localWebViewRef = webView onWebViewInstanceCreated(webView) val fontCss = getFontCssInjection().replace("\n", " ") @@ -946,7 +1020,7 @@ fun ChapterWebView( "javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null ) - }, modifier = Modifier.fillMaxSize() + } ) } 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 636889c..22c3877 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowUpward @@ -88,6 +89,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.ScreenRotation import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.Visibility @@ -104,6 +106,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -166,8 +169,9 @@ enum class ReaderTool(val title: String, val category: String) { PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"), KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), VISUAL_OPTIONS("Visual Options", "Overflow Menu"), + SCREEN_ORIENTATION("Screen Orientation", "Top Bar"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"), - TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), + TTS_SETTINGS("TTS Settings", "Overflow Menu"), TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu") } @@ -258,7 +262,8 @@ private val epubToolbarTools = setOf( ReaderTool.FORMAT, ReaderTool.SEARCH, ReaderTool.AI_FEATURES, - ReaderTool.TTS_CONTROLS + ReaderTool.TTS_CONTROLS, + ReaderTool.SCREEN_ORIENTATION ) @Composable @@ -272,6 +277,7 @@ fun EpubReaderTopBar( tapToNavigateEnabled: Boolean, volumeScrollEnabled: Boolean, isPageTurnAnimationEnabled: Boolean, + isRightToLeftPagination: Boolean, onNavigateBack: () -> Unit, isKeepScreenOn: Boolean, onToggleKeepScreenOn: (Boolean) -> Unit, @@ -281,12 +287,14 @@ fun EpubReaderTopBar( onToggleTapToNavigate: (Boolean) -> Unit, onToggleVolumeScroll: (Boolean) -> Unit, onTogglePageTurnAnimation: (Boolean) -> Unit, + onSetRightToLeftPagination: (Boolean) -> Unit, onStartAutoScroll: () -> Unit, onOpenTtsSettings: () -> Unit, onOpenTtsReplacements: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, onOpenVisualOptions: () -> Unit, + onOpenScreenOrientation: () -> Unit, onOpenSlider: () -> Unit, onOpenDrawer: () -> Unit, onToggleFormat: () -> Unit, @@ -414,17 +422,32 @@ fun EpubReaderTopBar( tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface ) } + ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( + text = stringResource(R.string.menu_screen_orientation), + description = stringResource(R.string.visual_options_screen_orientation_desc), + onClick = onOpenScreenOrientation + ) { + Icon( + Icons.Default.ScreenRotation, + contentDescription = stringResource(R.string.menu_screen_orientation), + tint = MaterialTheme.colorScheme.onSurface + ) + } else -> Unit } } Box { var showMoreMenu by remember { mutableStateOf(false) } var showHiddenToolsExpanded by remember { mutableStateOf(false) } + var showReadingModeExpanded by remember { mutableStateOf(false) } + var showTtsSettingsExpanded by remember { mutableStateOf(false) } TooltipIconButton( text = stringResource(R.string.tooltip_more_options), description = stringResource(R.string.tooltip_more_options_desc), onClick = { showHiddenToolsExpanded = false + showReadingModeExpanded = false + showTtsSettingsExpanded = false showMoreMenu = true } ) { @@ -435,6 +458,8 @@ fun EpubReaderTopBar( expanded = showMoreMenu, onDismissRequest = { showHiddenToolsExpanded = false + showReadingModeExpanded = false + showTtsSettingsExpanded = false showMoreMenu = false } ) { @@ -480,7 +505,8 @@ fun EpubReaderTopBar( onToggleFormat = onToggleFormat, onToggleSearch = onToggleSearch, onOpenAiHub = onOpenAiHub, - onToggleTts = onToggleTts + onToggleTts = onToggleTts, + onOpenScreenOrientation = onOpenScreenOrientation ) } } @@ -528,31 +554,63 @@ fun EpubReaderTopBar( if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onChangeRenderMode(RenderMode.VERTICAL_SCROLL) - }, + text = { Text(stringResource(R.string.menu_change_reading_mode)) }, + onClick = { showReadingModeExpanded = !showReadingModeExpanded }, trailingIcon = { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) ) - }) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onChangeRenderMode(RenderMode.PAGINATED) - }, - trailingIcon = { - if (currentRenderMode == RenderMode.PAGINATED) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - }) + } + ) + if (showReadingModeExpanded) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onChangeRenderMode(RenderMode.VERTICAL_SCROLL) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, + enabled = !isTtsActive, + onClick = { + onSetRightToLeftPagination(false) + showMoreMenu = false + onChangeRenderMode(RenderMode.PAGINATED) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + } + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, + enabled = !isTtsActive, + onClick = { + onSetRightToLeftPagination(true) + showMoreMenu = false + onChangeRenderMode(RenderMode.PAGINATED) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + } + }) + } HorizontalDivider() } if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) { @@ -664,39 +722,62 @@ fun EpubReaderTopBar( }) HorizontalDivider() } - if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) { + val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) + val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name) + if (showTtsVoiceSettings || showTtsReplacements) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onOpenTtsSettings() - }, + text = { Text(stringResource(R.string.menu_tts_settings)) }, + onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, leadingIcon = { Icon( Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp) ) - } - ) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, - onClick = { - showMoreMenu = false - onOpenTtsReplacements() }, - leadingIcon = { + trailingIcon = { Icon( - Icons.Default.GraphicEq, + Icons.Default.ArrowDropDown, contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) ) } ) + if (showTtsSettingsExpanded) { + if (showTtsVoiceSettings) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onOpenTtsSettings() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + if (showTtsReplacements) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { + showMoreMenu = false + onOpenTtsReplacements() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + } } } } @@ -706,6 +787,89 @@ fun EpubReaderTopBar( } } +@Composable +fun EpubJumpHistoryBar( + modifier: Modifier = Modifier, + showStandardBars: Boolean, + searchStateActive: Boolean, + backLabel: String?, + forwardLabel: String?, + onBack: () -> Unit, + onForward: () -> Unit, + onClear: () -> Unit +) { + AnimatedVisibility( + visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null), + enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)), + exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)), + modifier = modifier + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = onBack, + enabled = backLabel != null, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_desc_jump_back), + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = backLabel.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + TextButton( + onClick = onClear, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.action_clear), + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.action_clear), maxLines = 1) + } + + TextButton( + onClick = onForward, + enabled = forwardLabel != null, + modifier = Modifier.weight(1f) + ) { + Text( + text = forwardLabel.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(4.dp)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = stringResource(R.string.content_desc_jump_forward), + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + @androidx.annotation.OptIn(UnstableApi::class) @Composable fun EpubReaderBottomBar( @@ -723,6 +887,7 @@ fun EpubReaderBottomBar( onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, onToggleTts: () -> Unit, + onOpenScreenOrientation: () -> Unit, hiddenTools: Set, toolOrder: List, bottomTools: Set, @@ -835,6 +1000,16 @@ fun EpubReaderBottomBar( tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface ) } + ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( + text = stringResource(R.string.menu_screen_orientation), + description = stringResource(R.string.visual_options_screen_orientation_desc), + onClick = onOpenScreenOrientation + ) { + Icon( + imageVector = Icons.Default.ScreenRotation, + contentDescription = stringResource(R.string.menu_screen_orientation) + ) + } else -> Unit } } @@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) { ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp)) ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) + ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) } } @@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem( onToggleFormat: () -> Unit, onToggleSearch: () -> Unit, onOpenAiHub: () -> Unit, - onToggleTts: () -> Unit + onToggleTts: () -> Unit, + onOpenScreenOrientation: () -> Unit ) { val enabled = when (tool) { ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL @@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem( ReaderTool.SEARCH -> onToggleSearch() ReaderTool.AI_FEATURES -> onOpenAiHub() ReaderTool.TTS_CONTROLS -> onToggleTts() + ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation() else -> Unit } }, 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 c24acc9..1a4d11d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -154,12 +154,12 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.util.UnstableApi import com.aryan.reader.AiDefinitionResult -import com.aryan.reader.BannerMessage import com.aryan.reader.BuildConfig import com.aryan.reader.BuiltInThemes -import com.aryan.reader.CustomTopBanner import com.aryan.reader.MainViewModel import com.aryan.reader.R +import com.aryan.reader.ReaderScreenOrientationEffect +import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderThemePanel import com.aryan.reader.RenderMode import com.aryan.reader.SearchResult @@ -176,6 +176,8 @@ import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency +import com.aryan.reader.loadReaderScreenOrientationMode +import com.aryan.reader.loadEpubRightToLeftPagination import com.aryan.reader.loadReaderThemeId import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.loadTtsReplacementPreferences @@ -196,14 +198,18 @@ import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency +import com.aryan.reader.saveReaderScreenOrientationMode +import com.aryan.reader.saveEpubRightToLeftPagination import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.withTtsReplacements +import com.aryan.reader.shared.reader.ReaderJumpHistory import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -239,11 +245,14 @@ private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" private const val TOOL_ORDER_KEY = "reader_tool_order" private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools" +private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version" +private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1 private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore" private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume" private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TAG_LINK_NAV = "LINK_NAV" +private const val TAG_STABLE_PAGE_NAV = "StablePageNav" private fun View.bottomRoundedCornerRadiusPx(): Int { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0 @@ -283,12 +292,25 @@ private fun rememberBottomRoundedCornerPadding(view: View): Dp { private fun saveHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) - prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) } + prefs.edit { + putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) + putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) + } } private fun loadHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) - return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() + val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty() + val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) + if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) { + val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name + prefs.edit { + putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools) + putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) + } + return migratedHiddenTools + } + return savedHiddenTools } private fun saveToolOrder(context: Context, toolOrder: List) { @@ -615,7 +637,9 @@ fun EpubReaderHost( val window = (view.context as? Activity)?.window val activity = context as? Activity val scope = rememberCoroutineScope() - var bannerMessage by remember { mutableStateOf(null) } + fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { + viewModel.showBanner(message, isError, isPersistent) + } DisposableEffect(window, view) { onDispose { window?.let { @@ -652,9 +676,13 @@ fun EpubReaderHost( var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) } + var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } + var rightToLeftPagination by remember { mutableStateOf(loadEpubRightToLeftPagination(context)) } + var showScreenOrientationSheet by remember { mutableStateOf(false) } var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } + ReaderScreenOrientationEffect(screenOrientationMode) var volumeScrollEnabled by remember { mutableStateOf(loadVolumeScrollSetting(context)) @@ -904,6 +932,7 @@ fun EpubReaderHost( var showRecapPopup by remember { mutableStateOf(false) } var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) } + var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) } var chapterToLoadOnSwitch by remember { mutableStateOf(null) } var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) } var paginatedReconfigurationAnchor by remember { mutableStateOf(null) } @@ -985,11 +1014,17 @@ fun EpubReaderHost( ) } + LaunchedEffect(chapters.size) { + epubJumpHistory = epubJumpHistory.pruned(chapters.size) + } + var paginator by remember { mutableStateOf(null) } val paginatedPagerState = rememberPagerState(pageCount = { (paginator as? BookPaginator)?.totalPageCount ?: 0 }) var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } + var paginatedExplicitNavigationEpoch by remember(epubBook) { mutableLongStateOf(0L) } + var paginatedExplicitNavigationAnchor by remember(epubBook) { mutableStateOf(null) } val ttsController = viewModel.ttsController val ttsState by ttsController.ttsState.collectAsState() @@ -1105,13 +1140,6 @@ fun EpubReaderHost( } } - LaunchedEffect(bannerMessage) { - if (bannerMessage != null) { - delay(2500L) - bannerMessage = null - } - } - val configuration = LocalConfiguration.current var lastOrientation by remember { mutableIntStateOf(configuration.orientation) } @@ -1145,7 +1173,7 @@ fun EpubReaderHost( showInsufficientCreditsDialog = true ttsController.stop() } else { - bannerMessage = BannerMessage(message, isError = true) + showBanner(message, isError = true) } } } @@ -1479,9 +1507,8 @@ fun EpubReaderHost( return false } val pageIndex = - sourceOffset?.let { bookPaginator.findPageForCfiAndOffset(chapterIndex, sourceCfi, it) } - ?: bookPaginator.findPageForLocator(locator) - ?: bookPaginator.chapterStartPageIndices[chapterIndex] ?: run { + bookPaginator.findStablePageForLocator(locator) + ?: bookPaginator.findStableChapterStartPage(chapterIndex) ?: run { logTtsChapterDiag("Paginated locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex") return false } @@ -1912,6 +1939,13 @@ fun EpubReaderHost( DisposableEffect(Unit) { onDispose { Timber.d("Disposing reader. Last known chapter was ${latestChapterIndex}. Position saved periodically.") + webViewRefForTts = null + chapterHead = "" + chapterChunks = emptyList() + startPageThumbnail?.recycle() + startPageThumbnail = null + autoScrollResumeJob.value?.cancel() + autoScrollResumeJob.value = null } } @@ -2246,8 +2280,424 @@ fun EpubReaderHost( } } + fun Locator.toEpubJumpLocator(pageIndex: Int? = null, cfiOverride: String? = null): SharedReaderLocator { + return SharedReaderLocator( + chapterIndex = chapterIndex, + pageIndex = pageIndex, + cfi = cfiOverride ?: "android-locator:$chapterIndex:$blockIndex:$charOffset" + ) + } + + fun SharedReaderLocator.toAndroidLocatorOrNull(): Locator? { + val parts = cfi + ?.takeIf { it.startsWith("android-locator:") } + ?.split(':') + ?: return null + return Locator( + chapterIndex = parts.getOrNull(1)?.toIntOrNull() ?: return null, + blockIndex = parts.getOrNull(2)?.toIntOrNull() ?: return null, + charOffset = parts.getOrNull(3)?.toIntOrNull() ?: return null + ) + } + + fun currentEpubJumpLocator(): SharedReaderLocator? { + return when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> SharedReaderLocator( + chapterIndex = currentChapterIndex, + cfi = "android-scroll:$currentScrollYPosition" + ) + RenderMode.PAGINATED -> { + val pageIndex = paginatedPagerState.currentPage.takeIf { it >= 0 } + val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) + val fallbackLocator = lastKnownLocator?.takeIf { + currentChapterInPaginatedMode != null && it.chapterIndex == currentChapterInPaginatedMode + } + locator?.toEpubJumpLocator(pageIndex = pageIndex) + ?: fallbackLocator?.toEpubJumpLocator(pageIndex = pageIndex) + } + } + } + + fun chapterStartJumpLocator(chapterIndex: Int): SharedReaderLocator { + return SharedReaderLocator( + chapterIndex = chapterIndex, + href = chapters.getOrNull(chapterIndex)?.absPath, + cfi = "android-scroll:0" + ) + } + + fun fragmentJumpLocator(chapterIndex: Int, fragment: String?, href: String? = null): SharedReaderLocator { + return SharedReaderLocator( + chapterIndex = chapterIndex, + href = href ?: chapters.getOrNull(chapterIndex)?.absPath, + cfi = fragment?.let { "android-fragment:$it" } ?: "android-scroll:0" + ) + } + + fun cfiJumpLocator(chapterIndex: Int, cfi: String, textQuote: String? = null): SharedReaderLocator { + return SharedReaderLocator( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = textQuote + ) + } + + fun recordEpubJump(target: SharedReaderLocator?) { + epubJumpHistory = epubJumpHistory.record( + currentLocator = currentEpubJumpLocator(), + targetLocator = target, + chapterCount = chapters.size + ) + } + + fun paginatedJumpLocatorForPage( + pageIndex: Int, + targetLocator: Locator? = null, + fallbackChapterIndex: Int? = null, + allowPageFallback: Boolean = false + ): SharedReaderLocator? { + val safePageIndex = when { + pageIndex < 0 -> return null + paginatedPagerState.pageCount > 0 -> pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1) + else -> pageIndex + } + val bookPaginator = paginator as? BookPaginator + val resolvedLocator = targetLocator ?: bookPaginator?.getLocatorForPage(safePageIndex) + if (resolvedLocator != null) { + return resolvedLocator.toEpubJumpLocator(pageIndex = safePageIndex) + } + if (!allowPageFallback) return null + val chapterIndex = fallbackChapterIndex ?: bookPaginator?.findChapterIndexForPage(safePageIndex) + return SharedReaderLocator( + chapterIndex = chapterIndex, + pageIndex = safePageIndex, + cfi = "android-page:$safePageIndex" + ) + } + + suspend fun scrollPaginatedToJumpPage( + pageIndex: Int, + targetLocator: Locator? = null, + fallbackToChapterStart: Boolean = false + ) { + if (paginatedPagerState.pageCount <= 0) return + val targetPageIndex = pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1) + val bookPaginator = paginator as? BookPaginator + val resolvedLocator = targetLocator + ?: bookPaginator?.getLocatorForPage(targetPageIndex) + ?: if (fallbackToChapterStart) { + bookPaginator + ?.findChapterIndexForPage(targetPageIndex) + ?.let { Locator(chapterIndex = it, blockIndex = 0, charOffset = 0) } + } else { + null + } + + if (resolvedLocator != null) { + lastKnownLocator = resolvedLocator + } + val navigationEpoch = System.currentTimeMillis() + paginatedExplicitNavigationEpoch = navigationEpoch + paginatedExplicitNavigationAnchor = resolvedLocator + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "external_scroll_request requestedPage=$pageIndex targetPage=$targetPageIndex anchor=$resolvedLocator fallbackToChapterStart=$fallbackToChapterStart pageCount=${paginatedPagerState.pageCount} epoch=$navigationEpoch" + ) + bookPaginator?.onUserScrolledTo(targetPageIndex) + paginatedPagerState.scrollToPage(targetPageIndex) + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "external_scroll_complete targetPage=$targetPageIndex currentPage=${paginatedPagerState.currentPage} anchor=$resolvedLocator epoch=$navigationEpoch" + ) + } + + fun SharedReaderLocator.epubJumpLabel(): String { + val targetPageIndex = pageIndex + val targetCfi = cfi.orEmpty() + if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) { + return "Page ${targetPageIndex + 1}" + } + val chapter = chapterIndex + return if (chapter != null) { + chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}" + } else { + "Location" + } + } + + fun injectVerticalChunksThrough(targetChunk: Int) { + if (targetChunk < loadedChunkCount) return + (loadedChunkCount..targetChunk).forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } + loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) + } + + fun scrollCurrentVerticalChapterToFragment(fragment: String) { + val escapedFragment = escapeJsString(fragment) + val js = """ + (function() { + var targetId = '$escapedFragment'; + var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + return -2; + } + if (window.virtualization && window.virtualization.chunksData) { + for (var i = 0; i < window.virtualization.chunksData.length; i++) { + var chunkHtml = window.virtualization.chunksData[i]; + if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { + return i; + } + } + } + return -1; + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(js) { result -> + val chunkIdx = result?.toIntOrNull() ?: -1 + if (chunkIdx >= 0) { + injectVerticalChunksThrough(chunkIdx) + val scrollJs = """ + (function() { + var chunkIndex = $chunkIdx; + var fragmentId = '$escapedFragment'; + var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); + if (chunkDiv) { + if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + } + setTimeout(function() { + var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } else { + var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } + }, 150); + } + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(scrollJs, null) + } else if (chunkIdx == -1) { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } + } + + fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { + scope.launch { + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + val targetChunk = locator?.let { it.blockIndex / 20 } + cfiToLoad = cfi + initialScrollTargetForChapter = null + if (chapterIndex != currentChapterIndex) { + chunkTargetOverride = targetChunk?.coerceAtLeast(0) ?: 0 + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } else { + if (targetChunk != null && targetChunk >= 0) { + injectVerticalChunksThrough(targetChunk) + } + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToCfi('${escapeJsString(cfi)}');", + null + ) + } + } + } + + fun navigateToEpubJumpLocator(locator: SharedReaderLocator) { + scope.launch { + val chapterIndex = locator.chapterIndex?.coerceIn(0, max(0, chapters.lastIndex)) + val cfi = locator.cfi.orEmpty() + when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> { + clearPendingTtsRelocationState("epub_jump_history") + when { + cfi.startsWith("android-scroll:") -> { + val scrollY = cfi.substringAfter("android-scroll:").toIntOrNull() ?: 0 + initialScrollTargetForChapter = null + if (chapterIndex != null && chapterIndex != currentChapterIndex) { + currentScrollYPosition = scrollY + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } else { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0, $scrollY);", null) + } + } + cfi.startsWith("android-fragment:") -> { + val fragment = cfi.substringAfter("android-fragment:") + initialScrollTargetForChapter = null + fragmentToLoad = fragment + if (chapterIndex != null && chapterIndex != currentChapterIndex) { + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } else { + scrollCurrentVerticalChapterToFragment(fragment) + } + } + cfi.startsWith("android-search:") -> { + val parts = cfi.split(':') + val targetChunk = parts.getOrNull(1)?.toIntOrNull() ?: 0 + val occurrence = parts.getOrNull(2)?.toIntOrNull() ?: 0 + initialScrollTargetForChapter = null + if (chapterIndex != null && chapterIndex != currentChapterIndex) { + chunkTargetOverride = targetChunk.coerceAtLeast(0) + searchHighlightTarget = searchState.searchResults.firstOrNull { + it.locationInSource == chapterIndex && + it.chunkIndex == targetChunk && + it.occurrenceIndexInLocation == occurrence + } + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } else { + injectVerticalChunksThrough(targetChunk) + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToOccurrence($occurrence);", + null + ) + } + } + cfi.startsWith("android-locator:") -> { + val androidLocator = locator.toAndroidLocatorOrNull() + val targetCfi = androidLocator?.let { locatorConverter.getCfiFromLocator(epubBook, it) } + if (androidLocator != null && targetCfi != null) { + navigateVerticalToCfi(androidLocator.chapterIndex, targetCfi) + } else if (chapterIndex != null) { + initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } + } + cfi.startsWith("android-page:") && chapterIndex != null -> { + initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + if (chapterIndex != currentChapterIndex) { + currentChapterIndex = chapterIndex + } else { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } + cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null -> navigateVerticalToCfi(chapterIndex, cfi) + chapterIndex != null -> { + initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + if (chapterIndex != currentChapterIndex) { + currentChapterIndex = chapterIndex + } else { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } + } + } + + RenderMode.PAGINATED -> { + val bookPaginator = paginator as? BookPaginator + val directPage = locator.pageIndex?.takeIf { it in 0 until paginatedPagerState.pageCount } + isNavigatingToPosition = true + try { + when { + cfi.startsWith("android-locator:") && bookPaginator != null -> { + val androidLocator = locator.toAndroidLocatorOrNull() + val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) } + if (targetPage != null) { + scrollPaginatedToJumpPage(targetPage, androidLocator) + } else if (directPage != null) { + scrollPaginatedToJumpPage(directPage) + } + } + cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null && bookPaginator != null -> { + val androidLocator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) } + if (targetPage != null) { + scrollPaginatedToJumpPage(targetPage, androidLocator) + } else if (directPage != null) { + scrollPaginatedToJumpPage(directPage) + } else { + bookPaginator.findStableChapterStartPage(chapterIndex)?.let { + scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true) + } + } + } + cfi.startsWith("android-fragment:") && directPage != null -> scrollPaginatedToJumpPage(directPage) + cfi.startsWith("android-search:") && directPage != null -> scrollPaginatedToJumpPage(directPage) + cfi.startsWith("android-page:") && directPage != null -> scrollPaginatedToJumpPage(directPage) + directPage != null -> scrollPaginatedToJumpPage(directPage) + chapterIndex != null && bookPaginator != null -> { + bookPaginator.findStableChapterStartPage(chapterIndex)?.let { + scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true) + } + } + } + } finally { + isNavigatingToPosition = false + } + } + } + if (showBars) showBars = false + } + } + + fun goBackInEpubJumpHistory() { + val target = epubJumpHistory.backLocator ?: return + epubJumpHistory = epubJumpHistory.stepBack() + navigateToEpubJumpLocator(target) + } + + fun goForwardInEpubJumpHistory() { + val target = epubJumpHistory.forwardLocator ?: return + epubJumpHistory = epubJumpHistory.stepForward() + navigateToEpubJumpLocator(target) + } + fun navigateToSearchResult(index: Int) { Timber.tag("NavDiag").d("navigateToSearchResult index: $index") + val targetResult = searchState.searchResults.getOrNull(index) + if (targetResult != null && currentRenderMode == RenderMode.VERTICAL_SCROLL) { + recordEpubJump( + SharedReaderLocator( + chapterIndex = targetResult.locationInSource, + cfi = "android-search:${targetResult.chunkIndex}:${targetResult.occurrenceIndexInLocation}", + textQuote = targetResult.snippet.text + ) + ) + } + if (targetResult != null && currentRenderMode == RenderMode.PAGINATED) { + scope.launch { + searchState.currentSearchResultIndex = index + isNavigatingToPosition = true + try { + val bookPaginator = paginator as? BookPaginator ?: return@launch + val pageIdx = bookPaginator.findStablePageForSearchResult(targetResult) ?: return@launch + Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx") + val targetLocator = bookPaginator.getLocatorForPage(pageIdx) + paginatedJumpLocatorForPage(pageIdx, targetLocator) + ?.copy(textQuote = targetResult.snippet.text) + ?.let { recordEpubJump(it) } + scrollPaginatedToJumpPage(pageIdx, targetLocator) + } finally { + isNavigatingToPosition = false + } + } + return + } performSearchResultNavigation( index = index, searchState = searchState, @@ -2288,7 +2738,11 @@ fun EpubReaderHost( }, onPaginatedScrollToPage = { pageIdx -> Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx") - paginatedPagerState.scrollToPage(pageIdx) + val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(pageIdx) + paginatedJumpLocatorForPage(pageIdx, targetLocator) + ?.copy(textQuote = searchState.searchResults.getOrNull(index)?.snippet?.text) + ?.let { recordEpubJump(it) } + scrollPaginatedToJumpPage(pageIdx, targetLocator) } ) } @@ -2346,6 +2800,7 @@ fun EpubReaderHost( if (targetChapterIndex != -1) { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + recordEpubJump(fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath)) clearPendingTtsRelocationState("toc_entry_vertical") fragmentToLoad = entry.fragmentId if (targetChapterIndex != currentChapterIndex) { @@ -2434,15 +2889,26 @@ fun EpubReaderHost( Timber.tag("TOC_NAV_DEBUG").d("TOC Entry Clicked: ${entry.label}, targetChapter: $targetChapterIndex, anchor: ${entry.fragmentId}") isNavigatingByToc = true - - bookPaginator.findPageForAnchor(targetChapterIndex, entry.fragmentId) { targetPage -> - scope.launch { + try { + val targetPage = bookPaginator.findStablePageForAnchor(targetChapterIndex, entry.fragmentId) + if (targetPage != null) { + recordEpubJump( + fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath) + .copy(pageIndex = targetPage) + ) Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TOC_ENTRY_PAGINATED, from=$currentChapterIndex, to=$targetChapterIndex, page=$targetPage, anchor='${entry.fragmentId}', label='${entry.label}'") Timber.tag("TOC_NAV_DEBUG").d("Scrolling Pager to page: $targetPage") - paginatedPagerState.scrollToPage(targetPage) - isNavigatingByToc = false + val targetLocator = bookPaginator.getLocatorForPage(targetPage) + ?: if (entry.fragmentId == null) Locator(targetChapterIndex, 0, 0) else null + scrollPaginatedToJumpPage( + targetPage, + targetLocator, + fallbackToChapterStart = entry.fragmentId == null + ) } + } finally { + isNavigatingByToc = false } } else { Timber.tag("TOC_NAV_DEBUG").w("Paginator not ready for TOC navigation.") @@ -2461,6 +2927,7 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { if (index != currentChapterIndex) { + recordEpubJump(chapterStartJumpLocator(index)) clearPendingTtsRelocationState("sidebar_chapter_vertical") Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER, from=$currentChapterIndex, to=$index") @@ -2479,12 +2946,18 @@ fun EpubReaderHost( if (bookPaginator != null) { val currentFromPager = bookPaginator.findChapterIndexForPage(paginatedPagerState.currentPage) if (index != currentFromPager) { - val targetPage = bookPaginator.chapterStartPageIndices[index] - if (targetPage != null) { - Timber.tag(TAG_LINK_NAV) - .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage") - paginatedPagerState.scrollToPage(targetPage) - if (showBars) showBars = false + isNavigatingByToc = true + try { + val targetPage = bookPaginator.findStableChapterStartPage(index) + if (targetPage != null) { + recordEpubJump(chapterStartJumpLocator(index).copy(pageIndex = targetPage)) + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage") + scrollPaginatedToJumpPage(targetPage, Locator(index, 0, 0), fallbackToChapterStart = true) + if (showBars) showBars = false + } + } finally { + isNavigatingByToc = false } } } @@ -2498,6 +2971,7 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi @@ -2568,36 +3042,39 @@ fun EpubReaderHost( } } RenderMode.PAGINATED -> { + recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") isNavigatingToPosition = true - val locator = locatorConverter.getLocatorFromCfi( - book = epubBook, - chapterIndex = bookmark.chapterIndex, - cfi = bookmark.cfi - ) + try { + val bookPaginator = paginator as? BookPaginator + val locator = locatorConverter.getLocatorFromCfi( + book = epubBook, + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi + ) - if (locator != null) { - Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator") - val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator) - if (pageIndex != null) { - Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.") - paginatedPagerState.scrollToPage(pageIndex) + if (locator != null && bookPaginator != null) { + Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator") + val pageIndex = bookPaginator.findStablePageForLocator(locator) + if (pageIndex != null) { + Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.") + scrollPaginatedToJumpPage(pageIndex, locator) + } else { + Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.") + val chapterStartPage = bookPaginator.findStableChapterStartPage(bookmark.chapterIndex) + if (chapterStartPage != null) { + scrollPaginatedToJumpPage(chapterStartPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true) + } + } } else { - Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.") - val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(bookmark.chapterIndex) - if (chapterStartPage != null) { - paginatedPagerState.scrollToPage(chapterStartPage) + Timber.w("P-Mode Click: Failed to convert CFI to Locator. Falling back to stable chapter start.") + val fallbackPage = bookPaginator?.findStableChapterStartPage(bookmark.chapterIndex) + if (fallbackPage != null) { + scrollPaginatedToJumpPage(fallbackPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true) } } + } finally { isNavigatingToPosition = false - } else { - Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.") - paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex -> - scope.launch { - paginatedPagerState.scrollToPage(pageIndex) - isNavigatingToPosition = false - } - } } } } @@ -2611,6 +3088,7 @@ fun EpubReaderHost( drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) cfiToLoad = highlight.cfi val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) val targetChunk = locator?.let { it.blockIndex / 20 } @@ -2671,26 +3149,29 @@ fun EpubReaderHost( } } RenderMode.PAGINATED -> { + recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) isNavigatingToPosition = true - val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) - if (locator != null) { - val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator) - if (pageIndex != null) { - paginatedPagerState.scrollToPage(pageIndex) + try { + val bookPaginator = paginator as? BookPaginator + val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) + if (locator != null && bookPaginator != null) { + val pageIndex = bookPaginator.findStablePageForLocator(locator) + if (pageIndex != null) { + scrollPaginatedToJumpPage(pageIndex, locator) + } else { + val chapterStartPage = bookPaginator.findStableChapterStartPage(highlight.chapterIndex) + if (chapterStartPage != null) { + scrollPaginatedToJumpPage(chapterStartPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true) + } + } } else { - val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(highlight.chapterIndex) - if (chapterStartPage != null) { - paginatedPagerState.scrollToPage(chapterStartPage) + val fallbackPage = bookPaginator?.findStableChapterStartPage(highlight.chapterIndex) + if (fallbackPage != null) { + scrollPaginatedToJumpPage(fallbackPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true) } } + } finally { isNavigatingToPosition = false - } else { - paginator?.findPageForCfi(highlight.chapterIndex, highlight.cfi) { pageIndex -> - scope.launch { - paginatedPagerState.scrollToPage(pageIndex) - isNavigatingToPosition = false - } - } } } } @@ -2905,8 +3386,7 @@ fun EpubReaderHost( ) runRecap(chapterIndex, charsScrolled.toInt()) } else { - bannerMessage = - BannerMessage("Wait for book to load fully.", isError = true) + showBanner("Wait for book to load fully.", isError = true) } } } @@ -3438,12 +3918,39 @@ fun EpubReaderHost( onInternalLinkClick = { url -> scope.launch { val basePath = "file://${epubBook.extractionBasePath}/" - val relativeUrl = url.removePrefix(basePath) + val rawRelativeUrl = url.removePrefix(basePath) + val relativeUrl = if (rawRelativeUrl != url) { + rawRelativeUrl + } else { + val decodedUrl = try { + java.net.URLDecoder.decode(url, "UTF-8") + } catch (_: Exception) { + url + } + decodedUrl.removePrefix(basePath) + } val pathPart = relativeUrl.substringBefore('#') val fragmentPart = relativeUrl.substringAfter('#', "").takeIf { it.isNotEmpty() } val decodedPath = try { java.net.URLDecoder.decode(pathPart, "UTF-8") } catch(e: Exception) { pathPart } - val targetChapterIndex = chapters.indexOfFirst { it.absPath == decodedPath } + val renderedChapter = chapters.getOrNull(targetChapterIndex) + val renderedChapterDirectory = renderedChapter + ?.htmlFilePath + ?.substringBeforeLast('/', "") + .orEmpty() + .trim('/') + val decodedPathDirectory = decodedPath.trim('/') + val resolvedTargetChapterIndex = when { + pathPart.isBlank() -> targetChapterIndex + decodedPath.isBlank() -> targetChapterIndex + renderedChapterDirectory.isNotBlank() && decodedPathDirectory == renderedChapterDirectory -> targetChapterIndex + else -> chapters.indexOfFirst { + it.absPath == decodedPath || + it.htmlFilePath == decodedPath || + it.absPath.trim('/') == decodedPathDirectory || + it.htmlFilePath.trim('/') == decodedPathDirectory + } + } Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> url: $url") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> basePath: $basePath") @@ -3451,22 +3958,24 @@ fun EpubReaderHost( Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> pathPart: $pathPart") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> decodedPath: $decodedPath") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> fragmentPart: $fragmentPart") - Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $targetChapterIndex (current is $currentChapterIndex)") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $resolvedTargetChapterIndex (current is $currentChapterIndex)") - if (targetChapterIndex != -1) { - if (targetChapterIndex != currentChapterIndex) { - Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$targetChapterIndex, fragment='$fragmentPart'") + if (resolvedTargetChapterIndex != -1) { + recordEpubJump(fragmentJumpLocator(resolvedTargetChapterIndex, fragmentPart, chapters.getOrNull(resolvedTargetChapterIndex)?.absPath ?: decodedPath)) + if (resolvedTargetChapterIndex != currentChapterIndex) { + Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$resolvedTargetChapterIndex, fragment='$fragmentPart'") initialScrollTargetForChapter = null fragmentToLoad = fragmentPart currentScrollYPosition = 0 currentScrollHeightValue = 0 - currentChapterIndex = targetChapterIndex + currentChapterIndex = resolvedTargetChapterIndex } else { Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> Target is current chapter. Evaluating JS for fragment.") if (fragmentPart != null) { + val escapedFragment = escapeJsString(fragmentPart) val js = """ (function() { - var targetId = '$fragmentPart'; + var targetId = '$escapedFragment'; var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); @@ -3505,7 +4014,7 @@ fun EpubReaderHost( val scrollJs = """ (function() { var chunkIndex = $chunkIdx; - var fragmentId = '$fragmentPart'; + var fragmentId = '$escapedFragment'; var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); if (chunkDiv) { if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { @@ -3547,6 +4056,11 @@ fun EpubReaderHost( null ) }, + onWebViewDisposed = { webView -> + if (webViewRefForTts === webView) { + webViewRefForTts = null + } + }, onScrollFinished = { success -> Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success") isNavigatingToPosition = false @@ -3932,6 +4446,7 @@ fun EpubReaderHost( effectiveBg = effectiveBg, effectiveText = effectiveText, pagerState = paginatedPagerState, + isRightToLeftPagination = rightToLeftPagination, searchQuery = searchState.searchQuery, fontSizeMultiplier = currentFontSizeEm, lineHeightMultiplier = currentLineHeight, @@ -3953,6 +4468,9 @@ fun EpubReaderHost( activeTextureAlpha = activeTextureAlpha, initialChapterIndexInBook = lastKnownLocator?.chapterIndex, fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator, + explicitNavigationAnchor = paginatedExplicitNavigationAnchor, + explicitNavigationEpoch = paginatedExplicitNavigationEpoch, + isExternalNavigationInProgress = isNavigatingToPosition || isNavigatingByToc, onReconfigurationAnchorCaptured = { locator -> paginatedReconfigurationAnchor = locator lastKnownLocator = locator @@ -3989,7 +4507,11 @@ fun EpubReaderHost( when { tapOffset.x < oneQuarterWidthPx -> { scope.launch { - val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0) + val targetPage = if (rightToLeftPagination) { + (paginatedPagerState.currentPage + 1).coerceAtMost(paginatedPagerState.pageCount - 1) + } else { + (paginatedPagerState.currentPage - 1).coerceAtLeast(0) + } if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) @@ -4001,7 +4523,11 @@ fun EpubReaderHost( scope.launch { val pageCount = paginatedPagerState.pageCount if (pageCount > 0) { - val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1) + val targetPage = if (rightToLeftPagination) { + (paginatedPagerState.currentPage - 1).coerceAtLeast(0) + } else { + (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1) + } if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) @@ -4069,6 +4595,26 @@ fun EpubReaderHost( onFootnoteRequested = { html -> activeFootnoteHtml = html }, + onInternalLinkNavigated = { targetPageIndex -> + val bookPaginator = paginator as? BookPaginator + val targetChapter = bookPaginator?.findChapterIndexForPage(targetPageIndex) + val targetLocator = bookPaginator?.getLocatorForPage(targetPageIndex) + val navigationEpoch = System.currentTimeMillis() + paginatedExplicitNavigationEpoch = navigationEpoch + paginatedExplicitNavigationAnchor = targetLocator + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "internal_link_target targetPage=$targetPageIndex targetChapter=$targetChapter anchor=$targetLocator epoch=$navigationEpoch" + ) + if (targetLocator != null) { + lastKnownLocator = targetLocator + } + bookPaginator?.onUserScrolledTo(targetPageIndex) + paginatedJumpLocatorForPage( + pageIndex = targetPageIndex, + targetLocator = targetLocator, + fallbackChapterIndex = targetChapter + )?.let { recordEpubJump(it) } + }, onHighlightDeleted = { cfi -> val toRemove = userHighlights.find { it.cfi == cfi } if (toRemove != null) { @@ -4597,6 +5143,7 @@ fun EpubReaderHost( tapToNavigateEnabled = tapToNavigateEnabled, volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, + isRightToLeftPagination = rightToLeftPagination, hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, @@ -4665,6 +5212,10 @@ fun EpubReaderHost( isPageTurnAnimationEnabled = enabled savePageTurnAnimationSetting(context, enabled) }, + onSetRightToLeftPagination = { enabled -> + rightToLeftPagination = enabled + saveEpubRightToLeftPagination(context, enabled) + }, onToggleVolumeScroll = { enabled -> volumeScrollEnabled = enabled saveVolumeScrollSetting(context, enabled) @@ -4681,6 +5232,7 @@ fun EpubReaderHost( onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenVisualOptions = { showVisualOptionsSheet = true }, + onOpenScreenOrientation = { showScreenOrientationSheet = true }, onOpenAiHub = { showAiHubSheet = true }, onOpenSlider = { when (currentRenderMode) { @@ -4703,7 +5255,7 @@ fun EpubReaderHost( showBars = false startPageThumbnail = null } else { - bannerMessage = BannerMessage("Book is not paginated yet.") + showBanner("Book is not paginated yet.") } } } @@ -4903,6 +5455,19 @@ fun EpubReaderHost( ) } + EpubJumpHistoryBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomPadding + 45.dp), + showStandardBars = showBars, + searchStateActive = searchState.isSearchActive, + backLabel = epubJumpHistory.backLocator?.epubJumpLabel(), + forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(), + onBack = ::goBackInEpubJumpHistory, + onForward = ::goForwardInEpubJumpHistory, + onClear = { epubJumpHistory = epubJumpHistory.clear() } + ) + // Animated Bottom Bar EpubReaderBottomBar( isVisible = showBars, @@ -4938,7 +5503,7 @@ fun EpubReaderHost( showBars = false startPageThumbnail = null } else { - bannerMessage = BannerMessage("Book is not paginated yet.") + showBanner("Book is not paginated yet.") } } } @@ -4946,6 +5511,7 @@ fun EpubReaderHost( onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenScreenOrientation = { showScreenOrientationSheet = true }, onToggleFormat = { showFormatAdjustmentBars = !showFormatAdjustmentBars if (showFormatAdjustmentBars) { @@ -5238,8 +5804,6 @@ fun EpubReaderHost( onDismiss = { activeFootnoteHtml = null } ) } - - CustomTopBanner(bannerMessage = bannerMessage) } } @@ -5279,10 +5843,22 @@ fun EpubReaderHost( if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { sliderCurrentPage = page.toFloat() val scrollY = (page - 1) * currentClientHeightValue + recordEpubJump( + SharedReaderLocator( + chapterIndex = currentChapterIndex, + cfi = "android-scroll:$scrollY" + ) + ) webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) } else { sliderCurrentPage = page.toFloat() - paginatedPagerState.scrollToPage(page - 1) + val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1) + paginatedJumpLocatorForPage( + pageIndex = page - 1, + targetLocator = targetLocator, + allowPageFallback = true + )?.let { recordEpubJump(it) } + scrollPaginatedToJumpPage(page - 1, targetLocator) } } } @@ -5402,6 +5978,17 @@ fun EpubReaderHost( ) } + if (showScreenOrientationSheet) { + ReaderScreenOrientationSheet( + selectedMode = screenOrientationMode, + onModeSelected = { + screenOrientationMode = it + saveReaderScreenOrientationMode(context, it) + }, + onDismiss = { showScreenOrientationSheet = false } + ) + } + if (showFontSelectionSheet) { ModalBottomSheet( onDismissRequest = { showFontSelectionSheet = false }, diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt index 2fc9858..918089d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt @@ -26,12 +26,13 @@ import android.view.GestureDetector import android.view.MotionEvent import android.view.ActionMode import android.view.Menu -import android.view.MenuItem +import android.view.MenuInflater import android.webkit.WebView import android.graphics.Rect import android.os.Handler import android.os.Looper import android.view.View +import android.widget.PopupMenu import org.json.JSONObject enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM } @@ -59,7 +60,143 @@ class InteractiveWebView( private val scrollStopHandler = Handler(Looper.getMainLooper()) private var scrollStopRunnable: Runnable? = null - private var mCustomCallback: ActionMode.Callback? = null + private var activeSelectionActionMode: ActionMode? = null + + private fun clearPendingSelectionWork() { + scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) } + scrollStopRunnable = null + } + + private fun startLocalSelectionActionMode(): ActionMode { + activeSelectionActionMode?.let { existingMode -> + showCustomSelectionMenuFromCurrentSelection(existingMode) + return existingMode + } + + lateinit var localMode: ActionMode + localMode = LocalSelectionActionMode(this) { + if (activeSelectionActionMode === localMode) { + activeSelectionActionMode = null + } + onHideCustomSelectionMenu() + } + activeSelectionActionMode = localMode + showCustomSelectionMenuFromCurrentSelection(localMode) + return localMode + } + + private fun finishLocalSelectionActionMode() { + activeSelectionActionMode?.finish() + activeSelectionActionMode = null + } + + private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) { + val jsToGetSelectionDetails = """ + (function() { + var selection = window.getSelection(); + var selectedText = selection.toString().trim(); + if (selectedText.length === 0 || selection.rangeCount === 0) { + return null; + } + var range = selection.getRangeAt(0); + var rect = range.getBoundingClientRect(); + + // If getBoundingClientRect returns all zeros, try getClientRects() + if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) { + var clientRects = range.getClientRects(); + if (clientRects.length > 0) { + rect = clientRects[0]; // Use the first rect + } else { + return null; // No valid rect found + } + } + + // Ensure the rect has some dimension + if (rect.width === 0 && rect.height === 0) { + return null; + } + + return JSON.stringify({ + text: selectedText, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height + }); + })(); + """.trimIndent() + + evaluateJavascript(jsToGetSelectionDetails) { jsonResult -> + if (activeSelectionActionMode !== mode) { + return@evaluateJavascript + } + + if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) { + Timber.d("CustomSelection: JS returned null or invalid for selection details.") + mode.finish() + return@evaluateJavascript + } + + try { + val unquotedJsonResult = jsonResult.removeSurrounding("\"") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + + val selectionDetails = JSONObject(unquotedJsonResult) + val selectedText = selectionDetails.getString("text") + + if (selectedText.isBlank()) { + Timber.d("CustomSelection: Selected text is blank after JS processing.") + mode.finish() + return@evaluateJavascript + } + + val jsLeft = selectionDetails.getDouble("left") + val jsTop = selectionDetails.getDouble("top") + val jsRight = selectionDetails.getDouble("right") + val jsBottom = selectionDetails.getDouble("bottom") + val jsWidth = selectionDetails.getDouble("width") + val jsHeight = selectionDetails.getDouble("height") + + if (jsWidth == 0.0 && jsHeight == 0.0) { + Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop") + mode.finish() + return@evaluateJavascript + } + + val density = context.resources.displayMetrics.density + + val webViewLocation = IntArray(2) + getLocationOnScreen(webViewLocation) + val webViewX = webViewLocation[0] + val webViewY = webViewLocation[1] + + val selectionRectScreen = Rect( + (webViewX + jsLeft * density).toInt(), + (webViewY + jsTop * density).toInt(), + (webViewX + jsRight * density).toInt(), + (webViewY + jsBottom * density).toInt() + ) + + if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) { + Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY") + mode.finish() + return@evaluateJavascript + } + + Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen") + + onShowCustomSelectionMenu(selectedText, selectionRectScreen) { + mode.finish() + } + } catch (e: Exception) { + Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'") + mode.finish() + } + } + } private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { @@ -167,148 +304,12 @@ class InteractiveWebView( return super.onTouchEvent(event) } + // MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView + // selections use the app's Compose popup without starting the platform toolbar. override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? { if (type == ActionMode.TYPE_FLOATING) { - if (mCustomCallback == null) { - mCustomCallback = object : ActionMode.Callback2() { - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - Timber.d("CustomSelection: onCreateActionMode") - menu.clear() - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - Timber.d("CustomSelection: onPrepareActionMode") - menu.clear() - - val jsToGetSelectionDetails = """ - (function() { - var selection = window.getSelection(); - var selectedText = selection.toString().trim(); - if (selectedText.length === 0 || selection.rangeCount === 0) { - return null; - } - var range = selection.getRangeAt(0); - var rect = range.getBoundingClientRect(); - - // If getBoundingClientRect returns all zeros, try getClientRects() - if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) { - var clientRects = range.getClientRects(); - if (clientRects.length > 0) { - rect = clientRects[0]; // Use the first rect - } else { - return null; // No valid rect found - } - } - - // Ensure the rect has some dimension - if (rect.width === 0 && rect.height === 0) { - return null; - } - - return JSON.stringify({ - text: selectedText, - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height - }); - })(); - """.trimIndent() - - this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult -> - if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) { - Timber.d("CustomSelection: JS returned null or invalid for selection details.") - onHideCustomSelectionMenu() - mode.finish() - return@evaluateJavascript - } - - try { - val unquotedJsonResult = jsonResult.removeSurrounding("\"") - .replace("\\\"", "\"") - .replace("\\\\", "\\") - - val selectionDetails = JSONObject(unquotedJsonResult) - val selectedText = selectionDetails.getString("text") - - if (selectedText.isBlank()) { - Timber.d("CustomSelection: Selected text is blank after JS processing.") - onHideCustomSelectionMenu() - mode.finish() - return@evaluateJavascript - } - - val jsLeft = selectionDetails.getDouble("left") - val jsTop = selectionDetails.getDouble("top") - val jsRight = selectionDetails.getDouble("right") - val jsBottom = selectionDetails.getDouble("bottom") - val jsWidth = selectionDetails.getDouble("width") - val jsHeight = selectionDetails.getDouble("height") - - if (jsWidth == 0.0 && jsHeight == 0.0) { - Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop") - onHideCustomSelectionMenu() - mode.finish() - return@evaluateJavascript - } - - val density = context.resources.displayMetrics.density - - val webViewLocation = IntArray(2) - this@InteractiveWebView.getLocationOnScreen(webViewLocation) - val webViewX = webViewLocation[0] - val webViewY = webViewLocation[1] - - val selectionRectScreen = Rect( - (webViewX + jsLeft * density).toInt(), - (webViewY + jsTop * density).toInt(), - (webViewX + jsRight * density).toInt(), - (webViewY + jsBottom * density).toInt() - ) - - if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) { - Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY") - onHideCustomSelectionMenu() - mode.finish() - return@evaluateJavascript - } - - Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen") - - onShowCustomSelectionMenu(selectedText, selectionRectScreen) { - mode.finish() - } - - } catch (e: Exception) { - Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'") - onHideCustomSelectionMenu() - mode.finish() - } - } - return true - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)") - return false - } - - override fun onDestroyActionMode(mode: ActionMode) { - Timber.d("CustomSelection: onDestroyActionMode for mode: $mode") - onHideCustomSelectionMenu() - } - - override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { - super.onGetContentRect(mode, view, outRect) - Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect") - } - } - } - return super.startActionMode(mCustomCallback, type) + Timber.d("CustomSelection: handling floating action mode locally.") + return startLocalSelectionActionMode() } return super.startActionMode(originalCallback, type) } @@ -316,18 +317,79 @@ class InteractiveWebView( override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { super.onScrollChanged(l, t, oldl, oldt) - scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) } + clearPendingSelectionWork() scrollStopRunnable = Runnable { evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result -> val selectedText = result?.removeSurrounding("\"") if (!selectedText.isNullOrBlank()) { Timber.d("Selection exists after scroll. Restarting action mode.") - mCustomCallback?.let { - startActionMode(it, ActionMode.TYPE_FLOATING) - } + startLocalSelectionActionMode() } } } scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) } } -} \ No newline at end of file + + override fun onDetachedFromWindow() { + clearPendingSelectionWork() + finishLocalSelectionActionMode() + super.onDetachedFromWindow() + } + + override fun destroy() { + clearPendingSelectionWork() + finishLocalSelectionActionMode() + super.destroy() + } + + private class LocalSelectionActionMode( + anchorView: View, + private val onFinished: () -> Unit + ) : ActionMode() { + private val modeContext = anchorView.context + private val menu: Menu = PopupMenu(modeContext, anchorView).menu + private val menuInflater = MenuInflater(modeContext) + private var title: CharSequence? = null + private var subtitle: CharSequence? = null + private var customView: View? = null + private var finished = false + + override fun setTitle(title: CharSequence?) { + this.title = title + } + + override fun setTitle(resId: Int) { + title = modeContext.getText(resId) + } + + override fun setSubtitle(subtitle: CharSequence?) { + this.subtitle = subtitle + } + + override fun setSubtitle(resId: Int) { + subtitle = modeContext.getText(resId) + } + + override fun setCustomView(view: View?) { + customView = view + } + + override fun invalidate() = Unit + + override fun finish() { + if (finished) return + finished = true + onFinished() + } + + override fun getMenu(): Menu = menu + + override fun getTitle(): CharSequence? = title + + override fun getSubtitle(): CharSequence? = subtitle + + override fun getCustomView(): View? = customView + + override fun getMenuInflater(): MenuInflater = menuInflater + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index b8752e2..0a51c5c 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import kotlinx.serialization.ExperimentalSerializationApi @@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0 private const val PRIORITY_HIGH = 1 private const val PRIORITY_MEDIUM = 2 private const val PRIORITY_LOW = 3 +private const val TAG_STABLE_PAGE_NAV = "StablePageNav" data class TimedWord(val word: String, val startTime: Double, val startOffset: Int) @@ -187,6 +190,7 @@ class BookPaginator( private val paginationQueue = PriorityBlockingQueue() private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet() + private val chapterPaginationLocks = ConcurrentHashMap() private val navigationCallbacks = ConcurrentHashMap) -> Unit>>() private var paginationWorker: Job? = null @@ -430,9 +434,9 @@ class BookPaginator( Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.") null } else { - pageCache.put(chapterIndex, pages) applyPageRuntimeIndexes(chapterIndex, pages) updatePageCountsOnMain(chapterIndex, pages.size) + pageCache.put(chapterIndex, pages) Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.") pages } @@ -574,8 +578,86 @@ class BookPaginator( } } + private suspend fun ensureChapterPaginated(chapterIndex: Int): List? { + if (chapterIndex !in chapters.indices) { + Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.") + return null + } + + pageCache[chapterIndex]?.let { + Timber.tag(TAG_STABLE_PAGE_NAV) + .d("ensure_chapter hit_memory chapter=$chapterIndex pages=${it.size}") + return it + } + + val lock = chapterPaginationLocks.computeIfAbsent(chapterIndex) { Mutex() } + return lock.withLock { + pageCache[chapterIndex]?.also { + Timber.tag(TAG_STABLE_PAGE_NAV) + .d("ensure_chapter hit_after_wait chapter=$chapterIndex pages=${it.size}") + } ?: run { + Timber.tag(TAG_STABLE_PAGE_NAV) + .d("ensure_chapter paginate chapter=$chapterIndex finalized=${chapterIndex in finalizedChapterCounts}") + paginateChapter(chapterIndex) + } + } + } + + private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}" + ) + return resolveStableChapterStartPage( + chapterIndex = chapterIndex, + chapterCount = chapters.size, + pageCountsAreAccurate = pageCountsAreAccurate, + chapterStartPage = { chapterStartPageIndices[it] }, + isChapterFinalized = { it in finalizedChapterCounts }, + ensureChapterPaginated = { ensureChapterPaginated(it) != null } + ) + } + + suspend fun findStableChapterStartPage(chapterIndex: Int): Int? = withContext(Dispatchers.IO) { + val stableStart = ensureStableStartPageForChapter(chapterIndex) ?: return@withContext null + val targetPages = ensureChapterPaginated(chapterIndex) ?: return@withContext null + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "stable_chapter_start resolved chapter=$chapterIndex page=$stableStart targetPages=${targetPages.size}" + ) + stableStart.takeIf { targetPages.isNotEmpty() } + } + + suspend fun findStablePageForLocator(locator: Locator): Int? = withContext(Dispatchers.IO) { + val targetChapterIndex = locator.chapterIndex + Timber.tag("POS_DIAG").d("findStablePageForLocator: Searching for $locator") + Timber.tag(TAG_STABLE_PAGE_NAV).d("stable_locator request locator=$locator") + + val chapterPages = ensureChapterPaginated(targetChapterIndex) + val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex) + + Timber.tag("POS_DIAG").d( + "findStablePageForLocator: targetChapterIndex=$targetChapterIndex, stableStart=$chapterStartPage, chapterPages.size=${chapterPages?.size}" + ) + + if (chapterPages.isNullOrEmpty() || chapterStartPage == null) { + Timber.e("Stable locator navigation failed: Could not stabilize target chapter $targetChapterIndex.") + return@withContext null + } + + val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run { + Timber.tag("POS_DIAG").e("findStablePageForLocator: FAILED to resolve locator in chapter $targetChapterIndex") + return@withContext null + } + + val finalPageIndex = chapterStartPage + pageInChapter + Timber.tag("POS_DIAG").i("findStablePageForLocator: FOUND absolute page $finalPageIndex") + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "stable_locator resolved locator=$locator page=$finalPageIndex chapterStart=$chapterStartPage pageInChapter=$pageInChapter" + ) + finalPageIndex + } + suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List? { - val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex) + val pages = ensureChapterPaginated(chapterIndex) if (pages.isNullOrEmpty()) { Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.") return null @@ -794,7 +876,7 @@ class BookPaginator( } Timber.i("Worker: Starting pagination for chapter $chapterIndex.") - val pages = paginateChapter(chapterIndex) + val pages = ensureChapterPaginated(chapterIndex) if (pages != null) { Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.") @@ -836,6 +918,9 @@ class BookPaginator( val difference = actualPageCount - estimatedPageCount if (difference == 0) { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}" + ) if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) { coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } @@ -854,8 +939,16 @@ class BookPaginator( } rebuildChapterStartSnapshot() - if (chapterIndex < currentUserChapterIndex.value) { - pageShiftRequest.tryEmit(difference) + val currentUserChapter = currentUserChapterIndex.value + val shouldShiftCurrentPage = chapterIndex < currentUserChapter + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount" + ) + if (shouldShiftCurrentPage) { + val emitted = pageShiftRequest.tryEmit(difference) + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted" + ) } } @@ -1028,13 +1121,12 @@ class BookPaginator( ) Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.") - pageCache.put(chapterIndex, pages) - Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.") - applyPageRuntimeIndexes(chapterIndex, pages) savePageCacheAsync(chapter, chapterIndex, pages) updatePageCountsOnMain(chapterIndex, pages.size) + pageCache.put(chapterIndex, pages) + Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.") return pages } @@ -1105,12 +1197,68 @@ class BookPaginator( return Jsoup.parse(htmlToParse).body().text() } - private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int { - if (targetChapterIndex <= 0) { - return 0 + suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) { + Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'") + + if (anchor.isNullOrBlank()) { + return@withContext findStableChapterStartPage(chapterIndex) } - val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0 - return startIndex + + // 1. QUICK LOOKUP: Check the Anchor Index first + val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor) + + val (targetChapter, targetBlock) = if (indexEntry != null) { + Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}") + indexEntry.chapterIndex to indexEntry.blockIndex + } else { + Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex") + chapterIndex to null + } + + // 2. ENSURE PAGINATION: Get pages for the determined chapter + val chapterPages = ensureChapterPaginated(targetChapter) + val chapterStartPage = ensureStableStartPageForChapter(targetChapter) + + if (chapterPages == null || chapterStartPage == null) { + Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.") + return@withContext null + } + + val indexedPageInChapter = targetBlock?.let { blockIndex -> + chapterPageNavigationIndex[targetChapter] + ?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex } + ?.pageInChapter + } ?: chapterAnchorPageIndex[targetChapter]?.get(anchor) + + if (indexedPageInChapter != null) { + val finalPage = chapterStartPage + indexedPageInChapter + Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage") + return@withContext finalPage + } + + // 3. FIND PAGE + var targetPageInChapter = 0 + var found = false + + for ((pageIndex, page) in chapterPages.withIndex()) { + val isMatch = if (targetBlock != null) { + // Fast path: We know exactly which block we are looking for + page.content.any { it.blockIndex == targetBlock } + } else { + // Slow path: Linear ID scan (fallback) + page.content.any { containsAnchor(it, anchor) } + } + + if (isMatch) { + targetPageInChapter = pageIndex + found = true + break + } + } + + val finalPage = chapterStartPage + targetPageInChapter + Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)") + finalPage } override fun findPageForAnchor( @@ -1119,70 +1267,8 @@ class BookPaginator( onResult: (pageIndex: Int) -> Unit ) { coroutineScope.launch(Dispatchers.IO) { - Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'") - - if (anchor.isNullOrBlank()) { - val start = chapterStartPageIndices[chapterIndex] ?: 0 - withContext(Dispatchers.Main) { onResult(start) } - return@launch - } - - // 1. QUICK LOOKUP: Check the Anchor Index first - val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor) - - val (targetChapter, targetBlock) = if (indexEntry != null) { - Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}") - indexEntry.chapterIndex to indexEntry.blockIndex - } else { - Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex") - chapterIndex to null - } - - // 2. ENSURE PAGINATION: Get pages for the determined chapter - val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter) - val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0 - - if (chapterPages == null) { - withContext(Dispatchers.Main) { onResult(chapterStartPage) } - return@launch - } - - val indexedPageInChapter = targetBlock?.let { blockIndex -> - chapterPageNavigationIndex[targetChapter] - ?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex } - ?.pageInChapter - } ?: chapterAnchorPageIndex[targetChapter]?.get(anchor) - - if (indexedPageInChapter != null) { - val finalPage = chapterStartPage + indexedPageInChapter - Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage") - withContext(Dispatchers.Main) { onResult(finalPage) } - return@launch - } - - // 3. FIND PAGE - var targetPageInChapter = 0 - var found = false - - for ((pageIndex, page) in chapterPages.withIndex()) { - val isMatch = if (targetBlock != null) { - // Fast path: We know exactly which block we are looking for - page.content.any { it.blockIndex == targetBlock } - } else { - // Slow path: Linear ID scan (fallback) - page.content.any { containsAnchor(it, anchor) } - } - - if (isMatch) { - targetPageInChapter = pageIndex - found = true - break - } - } - - val finalPage = chapterStartPage + targetPageInChapter - Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)") - withContext(Dispatchers.Main) { onResult(finalPage) } + val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch + withContext(Dispatchers.Main) { onResult(page) } } } @@ -1234,69 +1320,79 @@ class BookPaginator( onNavigationComplete: (pageIndex: Int) -> Unit ) { coroutineScope.launch(Dispatchers.IO) { - Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'") - - val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href) - if (targetChapterPath == null) { - Timber.w("Could not resolve href '$href' to a valid chapter path.") - return@launch - } - - val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath } - if (targetChapterIndex == -1) { - Timber.w("Could not find chapter for path: $targetChapterPath") - return@launch - } - - findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete) + val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch + withContext(Dispatchers.Main) { onNavigationComplete(targetPage) } } } + suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) { + Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'") + + val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href) + if (targetChapterPath == null) { + Timber.w("Could not resolve href '$href' to a valid chapter path.") + return@withContext null + } + + val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath } + if (targetChapterIndex == -1) { + Timber.w("Could not find chapter for path: $targetChapterPath") + return@withContext null + } + + findStablePageForAnchor(targetChapterIndex, anchor) + } + + suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) { + val targetChapterIndex = result.locationInSource + Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex") + + val chapterPages = ensureChapterPaginated(targetChapterIndex) + val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex) + + if (chapterPages == null || chapterStartPage == null) { + Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.") + return@withContext null + } + + var targetPageInChapter = 0 + var occurrenceCount = 0 + + pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) { + for (block in page.content) { + val textToSearch = when (block) { + is ParagraphBlock -> block.content.text + is HeaderBlock -> block.content.text + is QuoteBlock -> block.content.text + is ListItemBlock -> block.content.text + else -> null + } + + if (textToSearch != null) { + var lastIndex = -1 + while (true) { + lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true) + if (lastIndex == -1) break + + if (occurrenceCount == result.occurrenceIndexInLocation) { + targetPageInChapter = pageIndex + Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex") + break@pageLoop + } + occurrenceCount++ + } + } + } + } + val finalPageIndex = chapterStartPage + targetPageInChapter + Timber.i("Search result found. Final page index: $finalPageIndex") + finalPageIndex + } + override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) { coroutineScope.launch(Dispatchers.IO) { - val targetChapterIndex = result.locationInSource - Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex") - - val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) - val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex) - - if (chapterPages == null) { - Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.") - return@launch - } - - var targetPageInChapter = 0 - var occurrenceCount = 0 - - pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) { - for (block in page.content) { - val textToSearch = when (block) { - is ParagraphBlock -> block.content.text - is HeaderBlock -> block.content.text - is QuoteBlock -> block.content.text - is ListItemBlock -> block.content.text - else -> null - } - - if (textToSearch != null) { - var lastIndex = -1 - while (true) { - lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true) - if (lastIndex == -1) break - - if (occurrenceCount == result.occurrenceIndexInLocation) { - targetPageInChapter = pageIndex - Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex") - break@pageLoop - } - occurrenceCount++ - } - } - } - } - val finalPageIndex = chapterStartPage + targetPageInChapter - Timber.i("Search result found. Final page index: $finalPageIndex") - withContext(Dispatchers.Main) { onResult(finalPageIndex) } + val page = findStablePageForSearchResult(result) ?: return@launch + withContext(Dispatchers.Main) { onResult(page) } } } @@ -1331,20 +1427,8 @@ class BookPaginator( } } - suspend fun findPageForLocator(locator: Locator): Int? { + private fun findPageInChapterForLocator(locator: Locator, chapterPages: List): Int? { val targetChapterIndex = locator.chapterIndex - Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator") - - val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) - val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0 - - Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}") - - if (chapterPages.isNullOrEmpty()) { - Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.") - return null - } - chapterTextRangeIndex[targetChapterIndex] ?.firstOrNull { range -> range.blockIndex == locator.blockIndex && @@ -1352,17 +1436,15 @@ class BookPaginator( (range.startOffset == range.endOffset && locator.charOffset == range.startOffset)) } ?.let { range -> - val finalPageIndex = chapterStartPage + range.pageInChapter - Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex") - return finalPageIndex + Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}") + return range.pageInChapter } chapterPageNavigationIndex[targetChapterIndex] ?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex } ?.let { entry -> - val finalPageIndex = chapterStartPage + entry.pageInChapter - Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex") - return finalPageIndex + Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}") + return entry.pageInChapter } var fallbackPageInChapter = -1 @@ -1370,7 +1452,7 @@ class BookPaginator( for ((pageIndex, page) in chapterPages.withIndex()) { val allTextBlocks = getAllTextBlocks(page.content) if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) { - Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})") + Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex") } for (textBlock in allTextBlocks) { if (textBlock.blockIndex == locator.blockIndex) { @@ -1384,15 +1466,13 @@ class BookPaginator( val isInside = locator.charOffset in startOffsetOnPage..() @@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) { init { handler.post { - setupWebView() + if (!isDestroyed) { + setupWebView() + } } } suspend fun awaitReady(): Boolean { + if (isDestroyed) return false Timber.d("awaitReady: Waiting for WebView and MathJax initialization...") return withTimeoutOrNull(10_000) { readySignal.await() @@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) { private fun setupWebView() { try { - if (BuildConfig.DEBUG) { - WebView.setWebContentsDebuggingEnabled(true) - } + if (isDestroyed) return webView = WebView(context).apply { @SuppressLint("SetJavaScriptEnabled") @@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) { } suspend fun render(mathML: String, originalAltText: String): RenderResult { + if (isDestroyed) { + return RenderResult.Failure(originalAltText) + } if (!awaitReady()) { Timber.e("WebView is not available or failed to initialize. Failing render.") return RenderResult.Failure(originalAltText) @@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) { } private fun processNextJob() { + if (isDestroyed) { + isProcessing = false + return + } synchronized(jobQueue) { if (jobQueue.isEmpty()) { isProcessing = false @@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) { } private fun executeRender() { + if (isDestroyed) { + isProcessing = false + return + } if (!isMathJaxReady) { Timber.d("executeRender called but MathJax not ready yet. Retrying...") handler.postDelayed({ executeRender() }, 100) @@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) { } fun destroy() { + isDestroyed = true + if (!readySignal.isCompleted) { + readySignal.complete(false) + } + val pendingJobs = synchronized(jobQueue) { + val copy = jobQueue.toList() + jobQueue.clear() + isProcessing = false + copy + } + pendingJobs.forEach { job -> + job.continuation(RenderResult.Failure(extractAltText(job.mathML))) + } + handler.removeCallbacksAndMessages(null) handler.post { - webView?.destroy() + webView?.releaseMathRendererResources() webView = null Timber.d("MathMLRenderer WebView destroyed.") } - synchronized(jobQueue) { - jobQueue.clear() - isProcessing = false + } + + private fun extractAltText(mathML: String): String = + mathML.substringAfter("alttext=\"", "").substringBefore("\"") + .ifBlank { "MathML rendering failed" } + + private fun WebView.releaseMathRendererResources() { + try { + stopLoading() + removeJavascriptInterface("AndroidBridge") + webChromeClient = null + webViewClient = WebViewClient() + loadDataWithBaseURL(null, "", "text/html", "UTF-8", null) + clearHistory() + removeAllViews() + destroy() + } catch (e: Exception) { + Timber.w(e, "Failed to fully release MathML WebView resources") } } @@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) { } else { Timber.e("onSvgReady FAILURE. Received empty SVG.") val job = synchronized(jobQueue) { jobQueue.firstOrNull() } - val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed" + val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed" completeCurrentJob(RenderResult.Failure(altText)) } } @@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index e8d8f4b..30d0293 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) { } private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f +private const val TAG_STABLE_PAGE_NAV = "StablePageNav" +private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float { return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier @@ -410,6 +413,30 @@ internal object CfiUtils { fun getPath(cfi: String): String = cfi.split(':').first() fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0 + fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull() + + fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean { + val candidateParts = pathParts(candidate) ?: return false + val startParts = pathParts(start) ?: return false + val endParts = pathParts(end) ?: return false + return comparePathParts(candidateParts, startParts) > 0 && + comparePathParts(candidateParts, endParts) < 0 + } + + private fun pathParts(cfi: String): List? { + val segments = getPath(cfi).split('/').filter { it.isNotEmpty() } + if (segments.isEmpty()) return null + return segments.map { it.toIntOrNull() ?: return null } + } + + private fun comparePathParts(first: List, second: List): Int { + val length = minOf(first.size, second.size) + for (index in 0 until length) { + val cmp = first[index].compareTo(second[index]) + if (cmp != 0) return cmp + } + return first.size.compareTo(second.size) + } } private fun highlightQueryInText( @@ -729,6 +756,7 @@ fun PaginatedReaderScreen( effectiveText: Color, pagerState: PagerState, isPageTurnAnimationEnabled: Boolean, + isRightToLeftPagination: Boolean = false, searchQuery: String, fontSizeMultiplier: Float, lineHeightMultiplier: Float, @@ -741,6 +769,9 @@ fun PaginatedReaderScreen( ttsHighlightInfo: TtsHighlightInfo?, initialChapterIndexInBook: Int?, fallbackLocatorForReconfiguration: Locator? = null, + explicitNavigationAnchor: Locator? = null, + explicitNavigationEpoch: Long = 0L, + isExternalNavigationInProgress: Boolean = false, onReconfigurationAnchorCaptured: (Locator) -> Unit = {}, onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {}, onPaginatorReady: (IPaginator) -> Unit, @@ -754,6 +785,7 @@ fun PaginatedReaderScreen( onStartTtsFromSelection: (String, Int) -> Unit, onNoteRequested: (String?) -> Unit, onFootnoteRequested: (String) -> Unit, + onInternalLinkNavigated: (Int) -> Unit = {}, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, @@ -784,6 +816,11 @@ fun PaginatedReaderScreen( } else Modifier var isNavigatingByLink by remember { mutableStateOf(false) } + var localExplicitNavigationAnchor by remember { mutableStateOf(null) } + var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) } + val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor) + val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch) + val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress) BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) { val textMeasurer = rememberTextMeasurer() @@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen( LaunchedEffect(paginator, pagerState) { paginator.pageShiftRequest.collect { shiftAmount -> - val anchor = resolvePaginatedReconfigurationAnchor( - currentPageLocator = anchorLocatorForReconfig, - fallbackLocator = latestFallbackLocatorForReconfiguration + if (pagerState.pageCount <= 0) { + Timber.tag(TAG_STABLE_PAGE_NAV) + .w("shift_drop reason=emptyPager shift=$shiftAmount") + return@collect + } + + val bookPaginator = paginator as? BookPaginator + val currentPageBeforeShift = pagerState.currentPage + val now = System.currentTimeMillis() + val externalAgeMs = if (latestExternalNavigationEpoch > 0L) { + now - latestExternalNavigationEpoch + } else { + -1L + } + val localAgeMs = if (localExplicitNavigationEpoch > 0L) { + now - localExplicitNavigationEpoch + } else { + -1L + } + val recentExternalNavigation = + externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS + val recentLocalNavigation = + localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS + val activeExplicitAnchor = when { + latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor + isNavigatingByLink -> localExplicitNavigationAnchor + else -> null + } + val recentExplicitAnchor = when { + recentExternalNavigation -> latestExternalNavigationAnchor + recentLocalNavigation -> localExplicitNavigationAnchor + else -> null + } + val activeExplicitAnchorSource = when { + activeExplicitAnchor == null -> null + latestIsExternalNavigationInProgress -> "explicit_external_active" + else -> "explicit_link" + } + val recentExplicitAnchorSource = when { + recentExplicitAnchor == null -> null + recentExternalNavigation -> "explicit_external_recent" + else -> "explicit_link_recent" + } + val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift) + val fallbackLocator = latestFallbackLocatorForReconfiguration + var anchorSource = "none" + val anchor = when { + anchorLocatorForReconfig != null -> { + anchorSource = "reconfiguration" + anchorLocatorForReconfig + } + activeExplicitAnchor != null -> { + anchorSource = activeExplicitAnchorSource ?: "explicit_active" + activeExplicitAnchor + } + fallbackLocator != null -> { + anchorSource = "last_known" + fallbackLocator + } + recentExplicitAnchor != null -> { + anchorSource = recentExplicitAnchorSource ?: "explicit_recent" + recentExplicitAnchor + } + currentPageLocator != null -> { + anchorSource = "current_page" + currentPageLocator + } + else -> null + } + + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs" ) + val resolvedPage = anchor?.let { locator -> - (paginator as? BookPaginator)?.findPageForLocator(locator) + bookPaginator?.findStablePageForLocator(locator) } if (resolvedPage != null) { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor" + ) pagerState.scrollToPage(resolvedPage) paginator.onUserScrolledTo(resolvedPage) } else { - val newPage = pagerState.currentPage + shiftAmount + val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0) + val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage) + Timber.tag(TAG_STABLE_PAGE_NAV).w( + "shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor" + ) pagerState.scrollToPage(newPage) paginator.onUserScrolledTo(newPage) } @@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen( uiState = uiState, pagerState = pagerState, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, + isRightToLeftPagination = isRightToLeftPagination, effectiveBg = effectiveBg, searchQuery = searchQuery, ttsHighlightInfo = ttsHighlightInfo, @@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen( } } }, + onInternalLinkNavigated = onInternalLinkNavigated, onLinkClick = { currentChapterPath, href, onNavComplete -> coroutineScope.launch(Dispatchers.IO) { - isNavigatingByLink = true - var isFootnote = false - var footnoteHtml: String? = null + withContext(Dispatchers.Main) { isNavigatingByLink = true } + try { + var isFootnote = false + var footnoteHtml: String? = null - val sourceChapter = - book.chaptersForPagination.find { it.absPath == currentChapterPath } - if (sourceChapter != null) { - val sourceHtml = sourceChapter.htmlContent.ifEmpty { - try { - File(book.extractionBasePath, sourceChapter.htmlFilePath) - .readText() - } catch (_: Exception) { - "" - } - } - if (sourceHtml.isNotEmpty()) { - val doc = Jsoup.parse(sourceHtml) - val safeHref = href.replace("\"", "\\\"") - val aTag = doc.select("a[href=\"$safeHref\"]").first() - - if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) { - isFootnote = true - } - } else if (href.startsWith("#")) { - isFootnote = true - } - } else if (href.startsWith("#")) { - isFootnote = true - } - - if (isFootnote) { - val decodedHref = try { - URLDecoder.decode(href, "UTF-8") - } catch (_: Exception) { - href - } - val parts = decodedHref.split('#', limit = 2) - val pathPart = parts[0] - val anchor = if (parts.size > 1) parts[1] else null - - if (anchor != null) { - val targetPath = if (pathPart.isBlank()) currentChapterPath else { + val sourceChapter = + book.chaptersForPagination.find { it.absPath == currentChapterPath } + if (sourceChapter != null) { + val sourceHtml = sourceChapter.htmlContent.ifEmpty { try { - URI(currentChapterPath).resolve(pathPart) - .normalize().path + File(book.extractionBasePath, sourceChapter.htmlFilePath) + .readText() } catch (_: Exception) { - null + "" } } + if (sourceHtml.isNotEmpty()) { + val doc = Jsoup.parse(sourceHtml) + val safeHref = href.replace("\"", "\\\"") + val aTag = doc.select("a[href=\"$safeHref\"]").first() - if (targetPath != null) { - val targetChapter = book.chaptersForPagination.find { + val linkType = aTag?.attr("epub:type").orEmpty() + val linkRole = aTag?.attr("role").orEmpty() + if ( + linkType.contains("noteref", ignoreCase = true) || + linkRole.contains("doc-noteref", ignoreCase = true) + ) { + isFootnote = true + } + } + } + + run { + val decodedHref = try { + URLDecoder.decode(href, "UTF-8") + } catch (_: Exception) { + href + } + val parts = decodedHref.split('#', limit = 2) + val pathPart = parts[0] + val anchor = if (parts.size > 1) parts[1] else null + + if (anchor != null) { + val targetPath = if (pathPart.isBlank()) currentChapterPath else { try { - URI(it.absPath).normalize().path == targetPath + URI(currentChapterPath).resolve(pathPart) + .normalize().path } catch (_: Exception) { - false + null } } - if (targetChapter != null) { - val targetHtml = targetChapter.htmlContent.ifEmpty { + if (targetPath != null) { + val targetChapter = book.chaptersForPagination.find { try { - File( - book.extractionBasePath, - targetChapter.htmlFilePath - ).readText() + URI(it.absPath).normalize().path == targetPath } catch (_: Exception) { - "" + false } } - if (targetHtml.isNotEmpty()) { - val doc = Jsoup.parse(targetHtml) - val noteEl = doc.getElementById(anchor) - if (noteEl != null) { - footnoteHtml = noteEl.html() + + if (targetChapter != null) { + val targetHtml = targetChapter.htmlContent.ifEmpty { + try { + File( + book.extractionBasePath, + targetChapter.htmlFilePath + ).readText() + } catch (_: Exception) { + "" + } + } + if (targetHtml.isNotEmpty()) { + val doc = Jsoup.parse(targetHtml) + val noteEl = doc.getElementById(anchor) + if (noteEl != null) { + val targetType = noteEl.attr("epub:type") + val targetRole = noteEl.attr("role") + val targetClass = noteEl.className() + val targetLooksLikeFootnote = + targetType.contains("footnote", ignoreCase = true) || + targetRole.contains("doc-footnote", ignoreCase = true) || + targetClass.contains("footnote", ignoreCase = true) + if (isFootnote || targetLooksLikeFootnote) { + footnoteHtml = noteEl.html() + } + } } } } } } - } - withContext(Dispatchers.Main) { if (!footnoteHtml.isNullOrBlank()) { - onFootnoteRequested(footnoteHtml) - isNavigatingByLink = false + withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) } } else { - paginator.navigateToHref(currentChapterPath, href) { - onNavComplete(it) - isNavigatingByLink = false + val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href) + withContext(Dispatchers.Main) { + if (targetPage != null) { + val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage) + val navigationEpoch = System.currentTimeMillis() + localExplicitNavigationAnchor = targetAnchor + localExplicitNavigationEpoch = navigationEpoch + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch" + ) + paginator.onUserScrolledTo(targetPage) + onNavComplete(targetPage) + } else { + Timber.tag(TAG_STABLE_PAGE_NAV).w( + "link_failed href=$href currentChapterPath=$currentChapterPath" + ) + } } } + } finally { + withContext(Dispatchers.Main) { isNavigatingByLink = false } } } }, @@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean = return null } -private fun getHighlightOffsetsInBlock( +internal fun getHighlightOffsetsInBlock( block: TextContentBlock, highlight: UserHighlight ): IntRange? { if (block.cfi == null) return null @@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock( val parts = highlight.cfi.split('|') val startCfi = parts.firstOrNull() ?: highlight.cfi val endCfi = parts.lastOrNull() + val isMultipartHighlight = endCfi != null && endCfi != startCfi @Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) { is ParagraphBlock -> block.startCharOffsetInSource @@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock( is ListItemBlock -> block.startCharOffsetInSource else -> 0 } + val blockEndAbs = block.endCharOffsetInSource + .takeIf { it > blockStartAbs } + ?: (blockStartAbs + block.content.text.length) Timber.d( "getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}" @@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock( ) } - var isAfterStart = false - var isBeforeEnd = true - - if (relevantPart == null) { - if (startCfi.isNotEmpty()) { - try { - if (CfiUtils.compare(block.cfi!!, startCfi) > 0) { - isAfterStart = true - } - } catch (_: Exception) { - } - } - - if (endCfi != null && endCfi != startCfi) { - try { - val endPath = CfiUtils.getPath(endCfi) - val cmp = CfiUtils.compare(blockPath, endPath) - Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp") - if (CfiUtils.compare(blockPath, endPath) > 0) { - isBeforeEnd = false - } - } catch (_: Exception) { - } - } - } - - Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd") - - if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) { - return null - } - val blockText = block.content.text val highlightText = highlight.text if (blockText.isEmpty() || highlightText.isEmpty()) return null - if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length - if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length - var startIndex = blockText.indexOf(highlightText, ignoreCase = false) - if (startIndex == -1) { - startIndex = blockText.indexOf(highlightText, ignoreCase = true) + val isIntermediateBlock = relevantPart == null && + isMultipartHighlight && + CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!) + + Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock") + + if (relevantPart == null) { + if (!isIntermediateBlock) return null + if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length + if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length + val normBlock = blockText.filter { !it.isWhitespace() } + val normHighlight = highlightText.filter { !it.isWhitespace() } + return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) { + 0 until blockText.length + } else { + null + } } if (relevantPart != null) { @@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock( Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches") if (startMatches || endMatches) { + val startAbs = CfiUtils.getOffsetOrNull(startCfi) + val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) } + if (startMatches && endMatches && startAbs != null && endAbs != null) { + val rangeStartAbs = minOf(startAbs, endAbs) + val rangeEndAbs = maxOf(startAbs, endAbs) + if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { + Timber.d( + " -> Skipping same-path split block outside highlight offsets. " + + "highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs" + ) + return null + } + } else { + if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null + if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null + } var s = 0 var e = blockText.length if (startMatches) { - val absOffset = CfiUtils.getOffset(startCfi) + val absOffset = startAbs ?: CfiUtils.getOffset(startCfi) val relOffset = absOffset - blockStartAbs if (relOffset < 0) { @@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock( } if (endMatches) { - val absOffset = CfiUtils.getOffset(endCfi!!) + val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) val relOffset = absOffset - blockStartAbs Timber.d( @@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock( } } - if (startIndex >= 0) { - return startIndex until (startIndex + highlightText.length) + if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length + if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length + + var startIndex = blockText.indexOf(highlightText, ignoreCase = false) + if (startIndex == -1) { + startIndex = blockText.indexOf(highlightText, ignoreCase = true) } - if (relevantPart == null) { - @Suppress("KotlinConstantConditions") if (isAfterStart) { - val normBlock = blockText.filter { !it.isWhitespace() } - val normHighlight = highlightText.filter { !it.isWhitespace() } - if (normHighlight.contains(normBlock, ignoreCase = true)) { - return 0 until blockText.length - } - } + if (startIndex >= 0) { + return startIndex until (startIndex + highlightText.length) } val match = findFuzzyMatch(blockText, highlightText) @@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent( uiState: PaginatedReaderUiState, pagerState: PagerState, isPageTurnAnimationEnabled: Boolean, + isRightToLeftPagination: Boolean = false, effectiveBg: Color, effectiveText: Color, searchQuery: String, @@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent( onGetPage: (Int) -> Page?, onGetChapterPath: (Int) -> String?, onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit, + onInternalLinkNavigated: (Int) -> Unit, onTap: (Offset?) -> Unit, isProUser: Boolean, isOss: Boolean, @@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent( } } }, - beyondViewportPageCount = 1 + beyondViewportPageCount = 1, + reverseLayout = isRightToLeftPagination ) { pageIndex -> val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction @@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent( } else { currentChapterPath?.let { path -> onLinkClick(path, href) { targetPageIndex -> + onInternalLinkNavigated(targetPageIndex) coroutineScope.launch { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}" + ) pagerState.scrollToPage(targetPageIndex) } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt new file mode 100644 index 0000000..eef2832 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt @@ -0,0 +1,93 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import kotlin.math.abs + +internal fun SpanStyle.withReaderLinkStyle( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): SpanStyle { + val linkStyle = readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + existingDecoration = textDecoration + ) + return copy( + color = linkStyle.color, + background = linkStyle.background, + textDecoration = linkStyle.textDecoration + ) +} + +internal fun readerLinkSpanStyle( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + existingDecoration: TextDecoration? = null +): SpanStyle { + val background = themeBackgroundColor.takeIf { it.isSpecified } + ?: if (isDarkTheme) Color.Black else Color.White + val text = themeTextColor.takeIf { it.isSpecified } + ?: if (isDarkTheme) Color.White else Color.Black + val linkColor = readerLinkColorForTheme(isDarkTheme, background, text) + val backgroundAlpha = if (background.safeLuminance() < 0.45f) 0.24f else 0.16f + return SpanStyle( + color = linkColor, + background = linkColor.copy(alpha = backgroundAlpha), + textDecoration = existingDecoration.withUnderline() + ) +} + +private fun readerLinkColorForTheme( + isDarkTheme: Boolean, + background: Color, + text: Color +): Color { + val backgroundLuminance = background.safeLuminance() + val textLuminance = text.safeLuminance() + val candidates = if (isDarkTheme || backgroundLuminance < 0.45f) { + listOf( + Color(0xFF7DD3FC), + Color(0xFF5EEAD4), + Color(0xFFA5B4FC), + Color(0xFFFDE68A), + Color.White + ) + } else { + listOf( + Color(0xFF005FCC), + Color(0xFF006D75), + Color(0xFF7A1E52), + Color(0xFF4A148C), + Color(0xFF111827) + ) + } + return candidates.firstOrNull { + it.contrastRatio(background) >= 4.5f && abs(it.safeLuminance() - textLuminance) >= 0.08f + } ?: candidates.maxByOrNull { it.contrastRatio(background) } + ?: if (isDarkTheme) Color(0xFF7DD3FC) else Color(0xFF005FCC) +} + +private fun TextDecoration?.withUnderline(): TextDecoration { + val current = this ?: TextDecoration.None + val decorations = mutableListOf() + if (current.contains(TextDecoration.LineThrough)) decorations += TextDecoration.LineThrough + decorations += TextDecoration.Underline + return TextDecoration.combine(decorations) +} + +private fun Color.contrastRatio(other: Color): Float { + val lighter = maxOf(safeLuminance(), other.safeLuminance()) + val darker = minOf(safeLuminance(), other.safeLuminance()) + return (lighter + 0.05f) / (darker + 0.05f) +} + +private fun Color.safeLuminance(): Float { + return if (isSpecified) luminance() else 0f +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt index 9bfccdc..6b830ea 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt @@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay( } addStringAnnotation(range.tag, item, range.start, range.end) } + this@applyReaderThemeForDisplay.getStringAnnotations("URL", 0, this@applyReaderThemeForDisplay.length).forEach { range -> + addStyle( + readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ), + range.start, + range.end + ) + } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt b/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt new file mode 100644 index 0000000..ae7de84 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt @@ -0,0 +1,23 @@ +package com.aryan.reader.paginatedreader + +internal suspend fun resolveStableChapterStartPage( + chapterIndex: Int, + chapterCount: Int, + pageCountsAreAccurate: Boolean, + chapterStartPage: (Int) -> Int?, + isChapterFinalized: (Int) -> Boolean, + ensureChapterPaginated: suspend (Int) -> Boolean +): Int? { + if (chapterIndex !in 0 until chapterCount) return null + + if (!pageCountsAreAccurate) { + for (prefixChapter in 0 until chapterIndex) { + if (!isChapterFinalized(prefixChapter)) { + val ready = ensureChapterPaginated(prefixChapter) + if (!ready) return null + } + } + } + + return chapterStartPage(chapterIndex) ?: if (chapterIndex == 0) 0 else null +} diff --git a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt index ae9e2f7..e2612c9 100644 --- a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt @@ -20,6 +20,7 @@ package com.aryan.reader.pdf import android.graphics.Rect +import android.graphics.RectF import timber.log.Timber import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box @@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ImageBitmap @@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp import kotlin.math.max import kotlin.math.roundToInt +internal data class MagnifierContentSource( + val sourceWidth: Int, + val sourceHeight: Int, + val contentLeft: Float, + val contentTop: Float, + val contentWidth: Float, + val contentHeight: Float +) { + val scaleX: Float + get() = if (contentWidth > 0f) sourceWidth.toFloat() / contentWidth else 1f + + val scaleY: Float + get() = if (contentHeight > 0f) sourceHeight.toFloat() / contentHeight else 1f + + fun sourceX(contentX: Float): Float = (contentX - contentLeft) * scaleX + + fun sourceY(contentY: Float): Float = (contentY - contentTop) * scaleY +} + +internal data class MagnifierSampleGeometry( + val srcLeft: Int, + val srcTop: Int, + val srcWidth: Int, + val srcHeight: Int, + val outputScaleX: Float, + val outputScaleY: Float +) + +internal fun calculateMagnifierSampleGeometry( + centerContentX: Float, + centerContentY: Float, + contentSource: MagnifierContentSource, + magnifierWidthPx: Float, + magnifierHeightPx: Float, + zoomFactor: Float +): MagnifierSampleGeometry? { + if ( + contentSource.sourceWidth <= 0 || + contentSource.sourceHeight <= 0 || + contentSource.contentWidth <= 0f || + contentSource.contentHeight <= 0f || + magnifierWidthPx <= 0f || + magnifierHeightPx <= 0f || + zoomFactor <= 0f + ) { + return null + } + + val sourceCenterX = contentSource.sourceX(centerContentX) + val sourceCenterY = contentSource.sourceY(centerContentY) + val sourceRectWidth = (magnifierWidthPx / zoomFactor * contentSource.scaleX).coerceAtLeast(1f) + val sourceRectHeight = (magnifierHeightPx / zoomFactor * contentSource.scaleY).coerceAtLeast(1f) + + val maxSrcLeft = max(0f, contentSource.sourceWidth.toFloat() - sourceRectWidth) + val maxSrcTop = max(0f, contentSource.sourceHeight.toFloat() - sourceRectHeight) + val srcLeft = (sourceCenterX - sourceRectWidth / 2f).coerceIn(0f, maxSrcLeft) + val srcTop = (sourceCenterY - sourceRectHeight / 2f).coerceIn(0f, maxSrcTop) + + val srcLeftInt = srcLeft.roundToInt().coerceIn(0, contentSource.sourceWidth - 1) + val srcTopInt = srcTop.roundToInt().coerceIn(0, contentSource.sourceHeight - 1) + val srcWidthInt = (contentSource.sourceWidth - srcLeftInt) + .coerceAtMost(sourceRectWidth.roundToInt().coerceAtLeast(1)) + .coerceAtLeast(1) + val srcHeightInt = (contentSource.sourceHeight - srcTopInt) + .coerceAtMost(sourceRectHeight.roundToInt().coerceAtLeast(1)) + .coerceAtLeast(1) + + return MagnifierSampleGeometry( + srcLeft = srcLeftInt, + srcTop = srcTopInt, + srcWidth = srcWidthInt, + srcHeight = srcHeightInt, + outputScaleX = magnifierWidthPx / srcWidthInt, + outputScaleY = magnifierHeightPx / srcHeightInt + ) +} + +internal fun mapContentRectToMagnifier( + contentRect: Rect, + contentSource: MagnifierContentSource, + sample: MagnifierSampleGeometry +): RectF { + val sourceLeft = contentSource.sourceX(contentRect.left.toFloat()) + val sourceTop = contentSource.sourceY(contentRect.top.toFloat()) + val sourceRight = contentSource.sourceX(contentRect.right.toFloat()) + val sourceBottom = contentSource.sourceY(contentRect.bottom.toFloat()) + + return RectF( + (sourceLeft - sample.srcLeft) * sample.outputScaleX, + (sourceTop - sample.srcTop) * sample.outputScaleY, + (sourceRight - sample.srcLeft) * sample.outputScaleX, + (sourceBottom - sample.srcTop) * sample.outputScaleY + ) +} + @Composable fun MagnifierComposable( sourceBitmap: ImageBitmap, tiles: List, currentScale: Float, magnifierCenterOnBitmap: Offset, + contentWidthPx: Int = sourceBitmap.width, + contentHeightPx: Int = sourceBitmap.height, modifier: Modifier = Modifier, magnifierWidth: Dp = 120.dp, magnifierHeight: Dp = 60.dp, zoomFactor: Float = 1.5f, - selectionRectsInBitmapCoords: List, + selectionRectsInContentCoords: List, highlightColor: Color, colorFilter: ColorFilter? = null ) { @@ -81,155 +180,71 @@ fun MagnifierComposable( } } else null - if (relevantTile != null) { - // --- HIGH-RES TILE PATH --- + val bitmapToUse: ImageBitmap + val contentSource: MagnifierContentSource + if (relevantTile != null && !relevantTile.bitmap.isRecycled) { Timber.d("Magnifier: Using HIGH-RES TILE path.") Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}") - val bitmapToUse = relevantTile.bitmap.asImageBitmap() - - val tileBitmapWidth = relevantTile.bitmap.width.toFloat() - val tileRenderRectWidth = relevantTile.renderRect.width().toFloat() - - val tileScale = if (tileRenderRectWidth > 0) { - tileBitmapWidth / tileRenderRectWidth - } else { - 1f - } - Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale") - - - val centerInTileBitmap = Offset( - x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale, - y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale + bitmapToUse = relevantTile.bitmap.asImageBitmap() + contentSource = MagnifierContentSource( + sourceWidth = bitmapToUse.width, + sourceHeight = bitmapToUse.height, + contentLeft = relevantTile.renderRect.left.toFloat(), + contentTop = relevantTile.renderRect.top.toFloat(), + contentWidth = relevantTile.renderRect.width().toFloat(), + contentHeight = relevantTile.renderRect.height().toFloat() ) - - Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap") - - val sourceRectWidth = magnifierWidthPx / zoomFactor - val sourceRectHeight = magnifierHeightPx / zoomFactor - Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight") - - val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f) - val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f) - Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)") - - val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f)) - val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f)) - val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft) - val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop) - Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)") - - val finalSrcLeftInt = clampedSrcLeft.roundToInt() - val finalSrcTopInt = clampedSrcTop.roundToInt() - - val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt) - .coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1) - val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt) - .coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1) - Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt") - - if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) { - Timber.w("Magnifier: Final source rect is invalid, returning.") - return@Canvas - } - - drawImage( - image = bitmapToUse, - srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt), - srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt), - dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()), - colorFilter = colorFilter - ) - - selectionRectsInBitmapCoords.forEach { rectInBitmap -> - val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale - val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale - val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale - val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale - - val finalLeft = translatedLeft - clampedSrcLeft - val finalTop = translatedTop - clampedSrcTop - val finalRight = translatedRight - clampedSrcLeft - val finalBottom = translatedBottom - clampedSrcTop - - val magnifiedLeft = finalLeft * zoomFactor - val magnifiedTop = finalTop * zoomFactor - val magnifiedRight = finalRight * zoomFactor - val magnifiedBottom = finalBottom * zoomFactor - - if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) { - drawRect( - color = highlightColor, - topLeft = Offset(magnifiedLeft, magnifiedTop), - size = androidx.compose.ui.geometry.Size( - width = magnifiedRight - magnifiedLeft, - height = magnifiedBottom - magnifiedTop - ) - ) - } - } - } else { - // --- LOW-RES / NO-ZOOM PATH --- Timber.d("Magnifier: Using LOW-RES (base bitmap) path.") - val sourceRectWidth = magnifierWidthPx / zoomFactor - val sourceRectHeight = magnifierHeightPx / zoomFactor - Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight") - - val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f) - val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f) - Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)") - - val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f)) - val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f)) - val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft) - val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop) - Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)") - - val finalSrcLeftInt = clampedSrcLeft.roundToInt() - val finalSrcTopInt = clampedSrcTop.roundToInt() - - val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt) - .coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1) - val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt) - .coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1) - Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt") - - if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) { - Timber.w("Magnifier: Final source rect is invalid, returning.") - return@Canvas - } - - drawImage( - image = sourceBitmap, - srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt), - srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt), - dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()), - colorFilter = colorFilter + bitmapToUse = sourceBitmap + contentSource = MagnifierContentSource( + sourceWidth = sourceBitmap.width, + sourceHeight = sourceBitmap.height, + contentLeft = 0f, + contentTop = 0f, + contentWidth = contentWidthPx.toFloat(), + contentHeight = contentHeightPx.toFloat() ) + } - selectionRectsInBitmapCoords.forEach { rectInBitmap -> - val translatedLeft = rectInBitmap.left - clampedSrcLeft - val translatedTop = rectInBitmap.top - clampedSrcTop - val rectWidthInBitmap = rectInBitmap.width().toFloat() - val rectHeightInBitmap = rectInBitmap.height().toFloat() + val sample = calculateMagnifierSampleGeometry( + centerContentX = magnifierCenterOnBitmap.x, + centerContentY = magnifierCenterOnBitmap.y, + contentSource = contentSource, + magnifierWidthPx = magnifierWidthPx, + magnifierHeightPx = magnifierHeightPx, + zoomFactor = zoomFactor + ) ?: run { + Timber.w("Magnifier: Source geometry is invalid, returning.") + return@Canvas + } + Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}") - val magnifiedLeft = translatedLeft * zoomFactor - val magnifiedTop = translatedTop * zoomFactor - val magnifiedWidth = rectWidthInBitmap * zoomFactor - val magnifiedHeight = rectHeightInBitmap * zoomFactor + drawImage( + image = bitmapToUse, + srcOffset = IntOffset(sample.srcLeft, sample.srcTop), + srcSize = IntSize(sample.srcWidth, sample.srcHeight), + dstSize = IntSize( + magnifierWidthPx.roundToInt().coerceAtLeast(1), + magnifierHeightPx.roundToInt().coerceAtLeast(1) + ), + colorFilter = colorFilter + ) - if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx && - magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) { - drawRect( - color = highlightColor, - topLeft = Offset(magnifiedLeft, magnifiedTop), - size = androidx.compose.ui.geometry.Size( - width = magnifiedWidth, - height = magnifiedHeight - ) + selectionRectsInContentCoords.forEach { contentRect -> + val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample) + if (magnifierRect.width() > 0f && magnifierRect.height() > 0f && + magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx && + magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx + ) { + drawRect( + color = highlightColor, + topLeft = Offset(magnifierRect.left, magnifierRect.top), + size = Size( + width = magnifierRect.width(), + height = magnifierRect.height() ) - } + ) } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt b/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt new file mode 100644 index 0000000..6c19bcd --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt @@ -0,0 +1,24 @@ +package com.aryan.reader.pdf + +internal const val PDF_BUBBLE_PREFETCH_RADIUS = 1 + +internal fun buildPdfBubblePrefetchOrder( + currentPage: Int, + totalPages: Int, + radius: Int = PDF_BUBBLE_PREFETCH_RADIUS +): List { + if (totalPages <= 0 || radius < 0) return emptyList() + + val clampedCurrentPage = currentPage.coerceIn(0, totalPages - 1) + val ordered = LinkedHashSet() + ordered += clampedCurrentPage + + for (distance in 1..radius) { + val next = clampedCurrentPage + distance + val previous = clampedCurrentPage - distance + if (next in 0 until totalPages) ordered += next + if (previous in 0 until totalPages) ordered += previous + } + + return ordered.toList() +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt index 3f7f9f9..cc916dd 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt @@ -21,8 +21,14 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.io.FileInputStream import java.io.FileOutputStream +import kotlin.math.roundToInt +import kotlin.math.sqrt import kotlin.random.Random +private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080 +private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048 +private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L + object PdfiumCoreProvider { val core: PdfiumCoreKt by lazy { PdfiumCoreKt(Dispatchers.Default) @@ -31,7 +37,7 @@ object PdfiumCoreProvider { internal data class DocumentCacheItem( val doc: ReaderDocument, - val pfd: ParcelFileDescriptor, + val pfd: ParcelFileDescriptor?, val totalPages: Int, val pageAspectRatios: List, val flatTableOfContents: List @@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) { if (evicted) { CoroutineScope(Dispatchers.IO).launch { try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) } - try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) } + try { oldValue.pfd?.close() } catch (e: Exception) { Timber.e(e) } } } } @@ -156,14 +162,34 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi page = doc.openPage(pageIndex) if (page == null) return@withContext null - val bitmapWidth = 1080 + val pageWidth = page.getPageWidthPoint() + val pageHeight = page.getPageHeightPoint() + if (pageWidth <= 0 || pageHeight <= 0) { + Timber.e("Invalid page size for page $pageIndex: ${pageWidth}x${pageHeight}") + return@withContext null + } + val aspectRatio = - page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat() + pageWidth.toFloat() / pageHeight.toFloat() if (aspectRatio.isNaN() || aspectRatio <= 0) { Timber.e("Invalid aspect ratio for page $pageIndex") return@withContext null } - val bitmapHeight = (bitmapWidth / aspectRatio).toInt() + + var bitmapWidth = PDF_PREVIEW_MAX_WIDTH_PX + var bitmapHeight = (bitmapWidth / aspectRatio).roundToInt() + + if (bitmapHeight > PDF_PREVIEW_MAX_HEIGHT_PX) { + bitmapHeight = PDF_PREVIEW_MAX_HEIGHT_PX + bitmapWidth = (bitmapHeight * aspectRatio).roundToInt().coerceAtLeast(1) + } + + val requestedBytes = bitmapWidth.toLong() * bitmapHeight.toLong() * 4L + if (requestedBytes > PDF_PREVIEW_MAX_BYTES) { + val scale = sqrt(PDF_PREVIEW_MAX_BYTES.toDouble() / requestedBytes.toDouble()) + bitmapWidth = (bitmapWidth * scale).roundToInt().coerceAtLeast(1) + bitmapHeight = (bitmapHeight * scale).roundToInt().coerceAtLeast(1) + } if (bitmapHeight <= 0) { Timber.e("Invalid calculated bitmap height for page $pageIndex") @@ -191,4 +217,4 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt index ba584a4..a6f6a3d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt @@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import timber.log.Timber import androidx.core.graphics.createBitmap +import com.aryan.reader.pdf.data.VirtualPage private const val MAX_FIXED_RECURSION = 128 @@ -281,6 +282,7 @@ internal fun PdfTocTreeItem( @Composable internal fun PdfNavigationDrawerContent( pdfDocument: ReaderDocument?, + documentKey: String, flatTableOfContents: List, bookmarks: Set, userHighlights: List, @@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent( }, contentAlignment = Alignment.Center ) { - var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) } + val thumbPageId = remember(documentKey, pageIdx) { + pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx)) + } + var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) } - LaunchedEffect(pageIdx, pdfDocument) { + LaunchedEffect(thumbPageId, pdfDocument) { if (thumb == null && pdfDocument != null) { withContext(kotlinx.coroutines.Dispatchers.IO) { try { - val cached = PdfThumbnailCache.get(pageIdx) + val cached = PdfThumbnailCache.get(thumbPageId) if (cached != null) { thumb = cached } else { @@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent( val bmp = createBitmap(thumbW, thumbH) bmp.eraseColor(android.graphics.Color.WHITE) p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false) - PdfThumbnailCache.put(pageIdx, bmp) + PdfThumbnailCache.put(thumbPageId, bmp) thumb = bmp } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 7acfc95..def8f77 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -123,6 +123,7 @@ import androidx.core.graphics.scale import androidx.core.graphics.set import com.aryan.reader.R import com.aryan.reader.SearchResult +import com.aryan.reader.isCanvasSafeBitmap import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.pdf.data.PdfAnnotation @@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate @@ -171,6 +171,47 @@ enum class InkType { PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT } +internal fun shouldReportPdfPageCamera( + isZoomEnabled: Boolean, + isVerticalScroll: Boolean, + isScrollLocked: Boolean, + lockedState: Triple?, + hasAppliedLockedState: Boolean +): Boolean { + return !isZoomEnabled || + isVerticalScroll || + !isScrollLocked || + lockedState == null || + hasAppliedLockedState +} + +internal fun initialPdfPageCamera( + isZoomEnabled: Boolean, + isVerticalScroll: Boolean, + isScrollLocked: Boolean, + lockedState: Triple? +): Pair { + return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) { + lockedState.first to Offset(lockedState.second, lockedState.third) + } else { + 1f to Offset.Zero + } +} + +internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( + isBubbleZoomModeActive: Boolean, + scale: Float, + isVerticalScroll: Boolean, + isZoomEnabled: Boolean, + isScrollLocked: Boolean +): Boolean { + return !isBubbleZoomModeActive && + scale > 1f && + !isVerticalScroll && + isZoomEnabled && + !isScrollLocked +} + data class EmbeddedAnnotation( val index: Int, val subtype: Int, @@ -186,8 +227,25 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) +internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String { + val sourcePageId = when (virtualPage) { + is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}" + is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}" + null -> "PDF_$pageIndex" + } + return "$documentKey:$sourcePageId" +} + +private fun Throwable.readablePdfErrorDetail(): String { + return localizedMessage?.takeIf { it.isNotBlank() } + ?: javaClass.simpleName.takeIf { it.isNotBlank() } + ?: "Unknown error" +} + private const val PDF_TILE_SIZE_DP = 256 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 +private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L +private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096 private const val PDF_TILE_SCALE_TOLERANCE = 0.06f private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f @@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap( } document.openPage(pageIndex)?.use { page -> - val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1) - val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1) + val safeRenderScale = safePdfBitmapRenderScale( + contentWidth = bubbleBounds.width(), + contentHeight = bubbleBounds.height(), + requestedScale = renderScale + ) + val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1) + val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1) val bitmap = createBitmap(cropWidth, cropHeight) try { page.renderPageBitmap( bitmap = bitmap, - startX = (-bubbleBounds.left * renderScale).roundToInt(), - startY = (-bubbleBounds.top * renderScale).roundToInt(), - drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth), - drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight), + startX = (-bubbleBounds.left * safeRenderScale).roundToInt(), + startY = (-bubbleBounds.top * safeRenderScale).roundToInt(), + drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth), + drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight), renderAnnot = true ) bitmap @@ -277,6 +340,23 @@ private suspend fun renderExpandedBubbleBitmap( } } +private fun safePdfBitmapRenderScale( + contentWidth: Float, + contentHeight: Float, + requestedScale: Float +): Float { + if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f + + val requestedWidth = contentWidth * requestedScale + val requestedHeight = contentHeight * requestedScale + val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0 + val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0)) + val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() / + max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0) + val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01) + return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat() +} + object PdfInkGeometry { fun calculateFountainPenPoints( points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float @@ -380,16 +460,15 @@ internal object PdfBitmapPool { fun get(size: Int): Bitmap = get(size, size) fun recycle(bitmap: Bitmap) { + // Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps. if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) { pool.offer(bitmap) - } else { - bitmap.recycle() } } fun clear() { while (!pool.isEmpty()) { - pool.poll()?.recycle() + pool.poll() } } } @@ -400,20 +479,20 @@ internal object PdfThumbnailCache { private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int) - private val memoryCache = object : LruCache(cacheSize) { - override fun sizeOf(key: Int, entry: CacheEntry): Int { + private val memoryCache = object : LruCache(cacheSize) { + override fun sizeOf(key: String, entry: CacheEntry): Int { return entry.sizeKb } } - fun get(pageIndex: Int): Bitmap? { - return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled } + fun get(pageId: String): Bitmap? { + return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled } } - fun put(pageIndex: Int, bitmap: Bitmap) { - if (get(pageIndex) == null) { + fun put(pageId: String, bitmap: Bitmap) { + if (get(pageId) == null) { val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1) - memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb)) + memoryCache.put(pageId, CacheEntry(bitmap, sizeKb)) } } @@ -475,6 +554,7 @@ data class PageSelectionData( @Composable internal fun PdfPageComposable( pdfDocument: StableHolder, + documentKey: String, pageIndex: Int, totalPages: Int, modifier: Modifier = Modifier, @@ -507,6 +587,7 @@ internal fun PdfPageComposable( isScrolling: Boolean = false, lazyListState: LazyListState? = null, isVerticalScroll: Boolean = false, + showPageNumberOverlay: Boolean = true, visualScaleProvider: () -> Float = { 1f }, clearSelectionTrigger: Long = 0L, resetZoomTrigger: Long = 0L, @@ -558,18 +639,13 @@ internal fun PdfPageComposable( onShowPanelPopup: (Bitmap) -> Unit = {} ) { val pdfDocumentItem = pdfDocument.item - var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } - var currentRenderedPageId by remember { mutableStateOf(null) } - - val targetPageId = remember(virtualPage, pageIndex) { - when (virtualPage) { - is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}" - is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}" - null -> "PDF_$pageIndex" - } + val targetPageId = remember(documentKey, virtualPage, pageIndex) { + pdfRenderPageId(documentKey, pageIndex, virtualPage) } - var isLoadingPage by remember { mutableStateOf(true) } - var pageErrorMessage by remember { mutableStateOf(null) } + var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) } + var currentRenderedPageId by remember(targetPageId) { mutableStateOf(null) } + var isLoadingPage by remember(targetPageId) { mutableStateOf(true) } + var pageErrorMessage by remember(targetPageId) { mutableStateOf(null) } val density = LocalDensity.current val context = LocalContext.current val viewConfiguration = LocalViewConfiguration.current @@ -581,12 +657,30 @@ internal fun PdfPageComposable( var ocrRipplePosition by remember { mutableStateOf(null) } var isTransforming by remember { mutableStateOf(false) } - var scale by remember { mutableFloatStateOf(1f) } - var offset by remember { mutableStateOf(Offset.Zero) } + val initialCamera = initialPdfPageCamera( + isZoomEnabled = isZoomEnabled, + isVerticalScroll = isVerticalScroll, + isScrollLocked = isScrollLocked, + lockedState = lockedState + ) + var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) } + var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) } var paginationPanFlingJob by remember { mutableStateOf(null) } + var hasAppliedLockedPaginationState by remember(targetPageId) { + mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f) + } + val shouldReportCamera = shouldReportPdfPageCamera( + isZoomEnabled = isZoomEnabled, + isVerticalScroll = isVerticalScroll, + isScrollLocked = isScrollLocked, + lockedState = lockedState, + hasAppliedLockedState = hasAppliedLockedPaginationState + ) - LaunchedEffect(scale, offset) { - onZoomAndPanChanged?.invoke(scale, offset) + LaunchedEffect(scale, offset, shouldReportCamera) { + if (shouldReportCamera) { + onZoomAndPanChanged?.invoke(scale, offset) + } } val currentOnSingleTap by rememberUpdatedState(onSingleTap) @@ -609,7 +703,7 @@ internal fun PdfPageComposable( val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex - var tiles by remember { mutableStateOf>(emptyList()) } + var tiles by remember(targetPageId) { mutableStateOf>(emptyList()) } val tileSizeDp = PDF_TILE_SIZE_DP.dp val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() } val latestEffectiveScale by rememberUpdatedState(effectiveScale) @@ -645,15 +739,15 @@ internal fun PdfPageComposable( } } - val selectionCharRange = remember { mutableStateOf?>(null) } - var activeDraggingHandle by remember { mutableStateOf(null) } - var selectedWordScreenRects by remember { mutableStateOf>(emptyList()) } - val startHandleContentPosition = remember { mutableStateOf(null) } - val endHandleContentPosition = remember { mutableStateOf(null) } + val selectionCharRange = remember(targetPageId) { mutableStateOf?>(null) } + var activeDraggingHandle by remember(targetPageId) { mutableStateOf(null) } + var selectedWordScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } + val startHandleContentPosition = remember(targetPageId) { mutableStateOf(null) } + val endHandleContentPosition = remember(targetPageId) { mutableStateOf(null) } - var actualBitmapWidthPx by remember { mutableIntStateOf(0) } - var actualBitmapHeightPx by remember { mutableIntStateOf(0) } - var currentPageRotation by remember { mutableIntStateOf(0) } + var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) } + var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) } + var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) } val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) @@ -736,7 +830,7 @@ internal fun PdfPageComposable( var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) } val magnifierZoomFactor = 2.0f - var customMenuState by remember { mutableStateOf(null) } + var customMenuState by remember(targetPageId) { mutableStateOf(null) } val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero @@ -826,7 +920,15 @@ internal fun PdfPageComposable( expandedBubbleIndex = -1 expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() expandedBubbleRender = null - if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) { + if ( + shouldResetPdfZoomAfterBubbleZoomCleanup( + isBubbleZoomModeActive = isBubbleZoomModeActive, + scale = scale, + isVerticalScroll = isVerticalScroll, + isZoomEnabled = isZoomEnabled, + isScrollLocked = isScrollLocked + ) + ) { coroutineScope.launch { Animatable(scale).animateTo(1f, tween(300)) { scale = this.value @@ -881,10 +983,10 @@ internal fun PdfPageComposable( } } - DisposableEffect(Unit) { + DisposableEffect(targetPageId) { onDispose { val currentBitmap = bitmapState - val cachedBitmap = PdfThumbnailCache.get(pageIndex) + val cachedBitmap = PdfThumbnailCache.get(targetPageId) if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) { currentBitmap.recycle() } @@ -895,16 +997,16 @@ internal fun PdfPageComposable( @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current // OCR - var ocrVisionTextForSelection by remember { mutableStateOf(null) } + var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf(null) } var isPerformingOcrForSelection by remember { mutableStateOf(false) } var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) } - var ocrSelectionSymbolIndices by remember { mutableStateOf?>(null) } - var allOcrSymbolsForSelection by remember { mutableStateOf>(emptyList()) } + var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf?>(null) } + var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf>(emptyList()) } - var highlightedTextScreenRects by remember { mutableStateOf>(emptyList()) } + var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f) - var allTextPageHighlightRects by remember { mutableStateOf>(emptyList()) } + var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf>(emptyList()) } var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) } @@ -937,7 +1039,7 @@ internal fun PdfPageComposable( val mergedSearchHighlightRects = remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) } - var pageLinks by remember { mutableStateOf>(emptyList()) } + var pageLinks by remember(targetPageId) { mutableStateOf>(emptyList()) } val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } } @@ -1091,9 +1193,9 @@ internal fun PdfPageComposable( onHighlightLoading(false) } - @Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf>(emptyList()) } - var standardAnnotScreenRects by remember { mutableStateOf>>(emptyList()) } - var imageScreenRects by remember { mutableStateOf>(emptyList()) } + @Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf>(emptyList()) } + var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf>>(emptyList()) } + var imageScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { @@ -1216,84 +1318,80 @@ internal fun PdfPageComposable( val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr) Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") if (count > 0) { - val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr) - Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") - if (count > 0) { - val allAnnots = (0 until count).mapNotNull { i -> - val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i) - if (subtype == annotLink) return@mapNotNull null + val allAnnots = (0 until count).mapNotNull { i -> + val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i) + if (subtype == annotLink) return@mapNotNull null - var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents") - if (contents.isNullOrBlank()) { - contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC") - } - - val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM") - val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT") - val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T") - - val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i) - val pdfRectF = if (pdfRectArray != null) { - android.graphics.RectF( - min(pdfRectArray[0], pdfRectArray[2]), - max(pdfRectArray[1], pdfRectArray[3]), - max(pdfRectArray[0], pdfRectArray[2]), - min(pdfRectArray[1], pdfRectArray[3]) - ) - } else android.graphics.RectF() - - EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) + var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents") + if (contents.isNullOrBlank()) { + contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC") } - val annotMap = allAnnots.associateBy { it.name } - val orphans = mutableListOf() + val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM") + val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT") + val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T") - allAnnots.forEach { annot -> - if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { - Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") - annotMap[annot.inReplyTo]?.replies?.add(annot) - } else { - orphans.add(annot) - } - } - - Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") - - val groupedRoots = mutableListOf>() - orphans.forEach { annot -> - val match = groupedRoots.find { group -> - val root = group.first() - val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } - android.graphics.RectF.intersects(inflatedRoot, annot.rect) - } - if (match != null) { - Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") - match.add(annot) - } else { - groupedRoots.add(mutableListOf(annot)) - } - } - - val rootsWithReplies = groupedRoots.map { group -> - val root = group.first() - if (group.size > 1) { - root.replies.addAll(group.drop(1)) - } - root - } - - finalDisplayList = rootsWithReplies.filter { - !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } - } - - mappedAnnots = finalDisplayList.map { annot -> - val screenRect = pageWrapper.mapRectToDevice( - 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, - currentPageRotation, annot.rect + val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i) + val pdfRectF = if (pdfRectArray != null) { + android.graphics.RectF( + min(pdfRectArray[0], pdfRectArray[2]), + max(pdfRectArray[1], pdfRectArray[3]), + max(pdfRectArray[0], pdfRectArray[2]), + min(pdfRectArray[1], pdfRectArray[3]) ) - annot to screenRect + } else android.graphics.RectF() + + EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) + } + + val annotMap = allAnnots.associateBy { it.name } + val orphans = mutableListOf() + + allAnnots.forEach { annot -> + if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { + Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") + annotMap[annot.inReplyTo]?.replies?.add(annot) + } else { + orphans.add(annot) } } + + Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") + + val groupedRoots = mutableListOf>() + orphans.forEach { annot -> + val match = groupedRoots.find { group -> + val root = group.first() + val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } + android.graphics.RectF.intersects(inflatedRoot, annot.rect) + } + if (match != null) { + Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") + match.add(annot) + } else { + groupedRoots.add(mutableListOf(annot)) + } + } + + val rootsWithReplies = groupedRoots.map { group -> + val root = group.first() + if (group.size > 1) { + root.replies.addAll(group.drop(1)) + } + root + } + + finalDisplayList = rootsWithReplies.filter { + !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } + } + + mappedAnnots = finalDisplayList.map { annot -> + val screenRect = pageWrapper.mapRectToDevice( + 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, + currentPageRotation, annot.rect + ) + annot to screenRect + } } } else { Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.") @@ -1323,7 +1421,7 @@ internal fun PdfPageComposable( Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.") val old = bitmapState bitmapState = null - @Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { } + @Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(targetPageId)) { } } } } @@ -1584,10 +1682,10 @@ internal fun PdfPageComposable( } } - var searchFocusedRects by remember { mutableStateOf>(emptyList()) } - var searchAllRects by remember { mutableStateOf>(emptyList()) } + var searchFocusedRects by remember(targetPageId) { mutableStateOf>(emptyList()) } + var searchAllRects by remember(targetPageId) { mutableStateOf>(emptyList()) } - var keyboardAdjustmentOriginalOffset by remember { mutableStateOf(null) } + var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf(null) } val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects } val mergedSearchAllRects = remember(searchAllRects) { searchAllRects } @@ -1932,10 +2030,6 @@ internal fun PdfPageComposable( } } - val errorSelection = stringResource(R.string.error_selection) - val errorOcrSelection = stringResource(R.string.error_ocr_selection) - val errorProcessingPage = stringResource(R.string.error_processing_page) - BoxWithConstraints( modifier = modifier .onGloballyPositioned { layoutCoordinates = it } @@ -2706,7 +2800,10 @@ internal fun PdfPageComposable( Timber.e( e, "Long press: Error during OCR text selection" ) - pageErrorMessage = errorOcrSelection + pageErrorMessage = context.getString( + R.string.error_ocr_selection, + e.readablePdfErrorDetail() + ) } finally { isPerformingOcrForSelection = false ocrRipplePosition = null @@ -2727,7 +2824,10 @@ internal fun PdfPageComposable( e, "Error during long press text selection on page $pageIndex" ) - pageErrorMessage = errorSelection + pageErrorMessage = context.getString( + R.string.error_selection, + e.readablePdfErrorDetail() + ) customMenuState = null selectionCharRange.value = null selectedWordScreenRects = emptyList() @@ -2774,7 +2874,8 @@ internal fun PdfPageComposable( selectedTool, isStylusOnlyMode, userHighlightScreenRects, - bubbleTapSlopPx + bubbleTapSlopPx, + isScrollLocked ) { val isTapDetectionAllowed = !isEditMode || selectedTool == InkType.TEXT || @@ -3040,7 +3141,7 @@ internal fun PdfPageComposable( onScaleChanged(scale) } } - } else if (isVerticalScroll && currentOnDoubleTap != null) { + } else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) { currentOnDoubleTap!!(tapOffset) } }) @@ -3276,7 +3377,7 @@ internal fun PdfPageComposable( val startOffset = offset paginationPanFlingJob = coroutineScope.launch { try { - coroutineScope { + kotlinx.coroutines.coroutineScope { launch { if (flingX != 0f) { Animatable(startOffset.x).animateDecay(flingX, decay) { @@ -3543,15 +3644,37 @@ internal fun PdfPageComposable( } } + var previousLockedViewportSize by remember { mutableStateOf?>(null) } + LaunchedEffect( pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, isScrollLocked, lockedState ) { - if (isScrollLocked && !isVerticalScroll && lockedState != null) { - scale = lockedState.first - offset = Offset(lockedState.second, lockedState.third) + val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight + val previousViewportSize = previousLockedViewportSize + val orientationChanged = previousViewportSize != null && + (previousViewportSize.first > previousViewportSize.second) != + (currentViewportSize.first > currentViewportSize.second) + previousLockedViewportSize = currentViewportSize + + if (isScrollLocked && !isVerticalScroll) { + if (orientationChanged) { + scale = 1f + offset = Offset.Zero + hasAppliedLockedPaginationState = true + Timber.tag("PdfLockDiagnostic").i( + "Orientation changed while locked; reset paginated zoom to fit on page $pageIndex" + ) + } else if (lockedState != null) { + scale = lockedState.first + offset = Offset(lockedState.second, lockedState.third) + hasAppliedLockedPaginationState = true + } else { + hasAppliedLockedPaginationState = true + } onScaleChanged(scale) } else if (!isScrollLocked && !isVerticalScroll) { + hasAppliedLockedPaginationState = false scale = 1f offset = Offset.Zero onScaleChanged(1f) @@ -3699,10 +3822,12 @@ internal fun PdfPageComposable( currentContainerMaxHeight, density, virtualPage, + targetPageId, isVisible, currentRenderedPageId ) { if (!isVisible && !isVerticalScroll) return@LaunchedEffect + pageErrorMessage = null val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() } val viewContainerHeightPx = @@ -3721,12 +3846,18 @@ internal fun PdfPageComposable( 1f / 1.414f } - var scaledWidth = viewContainerWidthPx - var scaledHeight = (scaledWidth / pageAspect).toInt() + val (scaledWidth, scaledHeight) = if (isVerticalScroll) { + viewContainerWidthPx to viewContainerHeightPx + } else { + var fittedWidth = viewContainerWidthPx + var fittedHeight = (fittedWidth / pageAspect).toInt() - if (scaledHeight > viewContainerHeightPx) { - scaledHeight = viewContainerHeightPx - scaledWidth = (scaledHeight * pageAspect).toInt() + if (fittedHeight > viewContainerHeightPx) { + fittedHeight = viewContainerHeightPx + fittedWidth = (fittedHeight * pageAspect).toInt() + } + + fittedWidth to fittedHeight } if (scaledWidth == actualBitmapWidthPx && @@ -3760,7 +3891,7 @@ internal fun PdfPageComposable( val old = bitmapState if (old != null && old !== finalBitmap) { - if (old !== PdfThumbnailCache.get(pageIndex)) { + if (old !== PdfThumbnailCache.get(targetPageId)) { old.recycle() } } @@ -3771,7 +3902,7 @@ internal fun PdfPageComposable( return@LaunchedEffect } - coroutineScope.launch { + kotlinx.coroutines.coroutineScope { var localBitmap: Bitmap? = null try { val renderResult = withContext(Dispatchers.IO) { @@ -3783,62 +3914,68 @@ internal fun PdfPageComposable( return@withContext null } val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null - val rotation = page.getPageRotation() - val screenDpi = (density.density * 160).roundToInt() - val originalWidthPdfUnits = page.getPageWidthPoint() - val originalHeightPdfUnits = page.getPageHeightPoint() + try { + val rotation = page.getPageRotation() + val originalWidthPdfUnits = page.getPageWidthPoint() + val originalHeightPdfUnits = page.getPageHeightPoint() - if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) { + if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) { + throw Exception("Invalid page dimensions") + } + + val aspectRatio = + originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat() + val (scaledWidth, scaledHeight) = if (isVerticalScroll) { + viewContainerWidthPx to viewContainerHeightPx + } else { + var fittedWidth = viewContainerWidthPx + var fittedHeight = (fittedWidth / aspectRatio).toInt() + + if (fittedHeight > viewContainerHeightPx) { + fittedHeight = viewContainerHeightPx + fittedWidth = (fittedHeight * aspectRatio).toInt() + } + + fittedWidth to fittedHeight + } + + if (scaledWidth == actualBitmapWidthPx && + scaledHeight == actualBitmapHeightPx && + bitmapState != null && + currentRenderedPageId == targetPageId + ) { + return@withContext null + } + + val MAX_BASE_DIMEN = 3000 + + val baseRenderScale = 1.5f + + var baseW = (scaledWidth * baseRenderScale).toInt() + var baseH = (scaledHeight * baseRenderScale).toInt() + + if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) { + val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) + baseW = (baseW * downScale).toInt().coerceAtLeast(1) + baseH = (baseH * downScale).toInt().coerceAtLeast(1) + } + + Timber.d( + "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})" + ) + val newBitmap = createBitmap(baseW, baseH) + localBitmap = newBitmap + page.renderPageBitmap( + newBitmap, + 0, 0, + baseW, baseH, + true + ) + + Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight)) + } finally { page.close() - throw Exception("Invalid page dimensions") } - - val aspectRatio = - originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat() - var scaledWidth = viewContainerWidthPx - var scaledHeight = (scaledWidth / aspectRatio).toInt() - - if (scaledHeight > viewContainerHeightPx) { - scaledHeight = viewContainerHeightPx - scaledWidth = (scaledHeight * aspectRatio).toInt() - } - - if (scaledWidth == actualBitmapWidthPx && - scaledHeight == actualBitmapHeightPx && - bitmapState != null && - currentRenderedPageId == targetPageId - ) { - page.close() - return@withContext null - } - - val MAX_BASE_DIMEN = 3000 - - val baseRenderScale = 1.5f - - var baseW = (scaledWidth * baseRenderScale).toInt() - var baseH = (scaledHeight * baseRenderScale).toInt() - - if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) { - val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) - baseW = (baseW * downScale).toInt().coerceAtLeast(1) - baseH = (baseH * downScale).toInt().coerceAtLeast(1) - } - - Timber.d( - "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})" - ) - val newBitmap = createBitmap(baseW, baseH) - localBitmap = newBitmap - page.renderPageBitmap( - newBitmap, - 0, 0, - baseW, baseH, - true - ) - page.close() - - Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight)) } if (renderResult != null) { @@ -3856,7 +3993,7 @@ internal fun PdfPageComposable( withContext(Dispatchers.IO) { if (old != null && old !== newBitmap && !old.isRecycled) { - val cached = PdfThumbnailCache.get(pageIndex) + val cached = PdfThumbnailCache.get(targetPageId) if (old !== cached) { old.recycle() } @@ -3866,14 +4003,17 @@ internal fun PdfPageComposable( val thumbHeight = newBitmap.height / 2 if (thumbWidth > 0 && thumbHeight > 0) { PdfThumbnailCache.put( - pageIndex, newBitmap.scale(thumbWidth, thumbHeight) + targetPageId, newBitmap.scale(thumbWidth, thumbHeight) ) } } } } catch (e: Exception) { if (e is CancellationException) throw e - pageErrorMessage = errorProcessingPage + pageErrorMessage = context.getString( + R.string.error_processing_page, + e.readablePdfErrorDetail() + ) } finally { isLoadingPage = false localBitmap?.recycle() @@ -4265,6 +4405,7 @@ internal fun PdfPageComposable( contentToScreenCoordinates = contentToScreenCoordinates, density = density, isVerticalScroll = isVerticalScroll, + showPageNumberOverlay = showPageNumberOverlay, isScrolling = isScrolling, isEditMode = isEditMode, selectedTool = selectedTool, @@ -4296,7 +4437,7 @@ internal fun PdfPageComposable( else -> { Text( - text = stringResource(R.string.error_unable_to_display_page), + text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1), modifier = Modifier .padding(16.dp) .align(Alignment.Center) @@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer( Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) { translate(left = centeringOffsetX, top = centeringOffsetY) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { - if (bitmapState != null && !bitmapState.isRecycled) { + if ( + bitmapState != null && + bitmapState.isCanvasSafeBitmap( + maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, + maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX + ) + ) { val dstW = if (targetWidth > 0) targetWidth else bitmapState.width val dstH = if (targetHeight > 0) targetHeight else bitmapState.height val srcSize = IntSize(bitmapState.width, bitmapState.height) @@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer( val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 if (needsTiling) { tiles.forEach { tile -> - if (!tile.bitmap.isRecycled) { + if ( + tile.bitmap.isCanvasSafeBitmap( + maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, + maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX + ) + ) { drawImage( image = tile.bitmap.asImageBitmap(), srcOffset = IntOffset.Zero, @@ -5086,6 +5238,7 @@ private fun PdfPageRenderer( contentToScreenCoordinates: (Offset) -> Offset, density: Density, isVerticalScroll: Boolean, + showPageNumberOverlay: Boolean, isScrolling: Boolean, isEditMode: Boolean, selectedTool: InkType, @@ -5282,7 +5435,7 @@ private fun PdfPageRenderer( } // Layer 4: Page Number Indicator - if (totalPages > 0) { + if (showPageNumberOverlay && totalPages > 0) { val pageNumColor = if (staticData.isDarkMode) { Color.White } else { @@ -5439,10 +5592,12 @@ private fun PdfPageRenderer( tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), currentScale = effectiveScale, magnifierCenterOnBitmap = magnifierCenterTarget, + contentWidthPx = staticData.targetWidth, + contentHeightPx = staticData.targetHeight, magnifierWidth = magnifierWidth, magnifierHeight = magnifierHeight, zoomFactor = effectiveZoomFactor, - selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item, + selectionRectsInContentCoords = selectionData.mergedSelectionRects.item, highlightColor = Color(0x6633B5E5), colorFilter = staticData.colorFilter.item, modifier = Modifier @@ -5593,6 +5748,21 @@ private fun PdfPageRenderer( } if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) { + val baseBitmap = staticData.bitmap.item ?: return@Canvas + if ( + !baseBitmap.isCanvasSafeBitmap( + maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, + maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX + ) + ) { + return@Canvas + } + val safeExpandedBubbleRender = expandedBubbleRender?.takeIf { + it.bitmap.isCanvasSafeBitmap( + maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, + maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX + ) + } val bubble = detectedBubbles[animatingBubbleIndex] val left = bubble.bounds.left + staticData.centeringOffsetX val top = bubble.bounds.top + staticData.centeringOffsetY @@ -5600,7 +5770,7 @@ private fun PdfPageRenderer( val logicalHeight = bubble.bounds.height() val pivotX = left + logicalWidth / 2f val pivotY = top + logicalHeight / 2f - val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor( + val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor( bubbleBounds = bubble.bounds, viewportWidth = staticData.canvasWidth, viewportHeight = staticData.canvasHeight @@ -5613,8 +5783,8 @@ private fun PdfPageRenderer( val dstOffset = IntOffset(left.toInt(), top.toInt()) val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt()) - val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat() - val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat() + val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat() + val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat() val srcOffset = IntOffset( (bubble.bounds.left * renderScaleX).toInt(), @@ -5651,12 +5821,12 @@ private fun PdfPageRenderer( ) drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint()) drawImage( - image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), - srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, - srcSize = if (expandedBubbleRender != null) { + image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(), + srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset, + srcSize = if (safeExpandedBubbleRender != null) { IntSize( - expandedBubbleRender.bitmap.width, - expandedBubbleRender.bitmap.height) + safeExpandedBubbleRender.bitmap.width, + safeExpandedBubbleRender.bitmap.height) } else { srcSize }, @@ -5675,12 +5845,12 @@ private fun PdfPageRenderer( } else { clipRect(left, top, left + logicalWidth, top + logicalHeight) { drawImage( - image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), - srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, - srcSize = if (expandedBubbleRender != null) { + image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(), + srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset, + srcSize = if (safeExpandedBubbleRender != null) { IntSize( - expandedBubbleRender.bitmap.width, - expandedBubbleRender.bitmap.height) + safeExpandedBubbleRender.bitmap.width, + safeExpandedBubbleRender.bitmap.height) } else { srcSize }, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt index 1af330f..85c0939 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -43,7 +43,11 @@ internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools" internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order" internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools" internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" +internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible" +internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" +private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version" +private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2 enum class PdfReaderTool(val title: String, val category: String) { DICTIONARY("External Apps", "Top Bar"), @@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) { OCR_LANGUAGE("OCR Language", "Overflow Menu"), READING_MODE("Reading Mode", "Overflow Menu"), KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), + SCREEN_ORIENTATION("Screen Orientation", "Top Bar"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"), - TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), + TTS_SETTINGS("TTS Settings", "Overflow Menu"), TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"), BOOKMARK("Bookmark", "Overflow Menu"), PAGE_MANAGEMENT("Page Management", "Overflow Menu"), @@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf( internal fun loadPdfHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() + val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty() + val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) + if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) { + val migratedHiddenTools = savedHiddenTools + setOf( + PdfReaderTool.SCREEN_ORIENTATION.name, + PdfReaderTool.HIGHLIGHT_ALL.name + ) + prefs.edit { + putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) + putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) + } + return migratedHiddenTools + } + return savedHiddenTools } internal fun savePdfHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } + prefs.edit { + putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) + putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) + } } internal fun loadPdfToolOrder(context: Context): List { @@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode { return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC } +internal fun savePdfVerticalPageGapVisible(context: Context, isVisible: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, isVisible) } +} + +internal fun loadPdfVerticalPageGapVisible(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, true) +} + +internal fun savePdfPageNumberOverlayVisible(context: Context, isVisible: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, isVisible) } +} + +internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true) +} + internal fun savePdfThemeId(context: Context, themeId: String) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(PDF_THEME_KEY, themeId) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt index 7157014..f0bbb43 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey import com.aryan.reader.R import com.aryan.reader.SearchResult @@ -154,9 +153,10 @@ fun PdfSearchResultsPanel( HorizontalDivider() LazyColumn(modifier = Modifier.testTag("SearchResultsList")) { - items(count = lazyResults.itemCount, key = lazyResults.itemKey { - "${it.locationInSource}_${it.occurrenceIndexInLocation}" - }, contentType = lazyResults.itemContentType { "SearchResult" }) { index -> + items( + count = lazyResults.itemCount, + contentType = lazyResults.itemContentType { "SearchResult" } + ) { index -> val result = lazyResults[index] if (result != null) { ListItem( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt index 7e436fd..0d90671 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ScreenRotation import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet( PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS + PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, + PdfReaderTool.SCREEN_ORIENTATION ) var localHiddenTools by remember { mutableStateOf(hiddenTools) } @@ -504,6 +508,7 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) { PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp)) PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) + PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) } } @@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) { @Composable fun PdfVisualOptionsSheet( systemUiMode: SystemUiMode, + showVerticalPageGap: Boolean, + showPageNumberOverlay: Boolean, onSystemUiModeChange: (SystemUiMode) -> Unit, + onShowVerticalPageGapChange: (Boolean) -> Unit, + onShowPageNumberOverlayChange: (Boolean) -> Unit, onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet( onOptionSelected = onSystemUiModeChange, getLabel = { it.title } ) + + Spacer(modifier = Modifier.height(20.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + PdfVisualOptionSwitchRow( + title = stringResource(R.string.visual_options_remove_page_gap), + description = stringResource(R.string.visual_options_remove_page_gap_desc), + checked = !showVerticalPageGap, + onCheckedChange = { removeGap -> + onShowVerticalPageGapChange(!removeGap) + } + ) + PdfVisualOptionSwitchRow( + title = stringResource(R.string.visual_options_hide_page_number_overlay), + description = stringResource(R.string.visual_options_hide_page_number_overlay_desc), + checked = !showPageNumberOverlay, + onCheckedChange = { hideOverlay -> + onShowPageNumberOverlayChange(!hideOverlay) + } + ) } } } + +@Composable +private fun PdfVisualOptionSwitchRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index 7b91896..3bd9bb7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled import com.aryan.reader.epubreader.SystemUiMode import kotlin.collections.isNotEmpty +internal val PdfTabStripHeight = 44.dp + private val pdfToolbarTools = setOf( PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, @@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf( PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, PdfReaderTool.EDIT_MODE, - PdfReaderTool.TTS_CONTROLS + PdfReaderTool.TTS_CONTROLS, + PdfReaderTool.SCREEN_ORIENTATION ) @OptIn(ExperimentalMaterial3Api::class) @@ -81,6 +84,7 @@ internal fun PdfTopBar( isScrollLocked: Boolean, isEditMode: Boolean, displayMode: DisplayMode, + isRightToLeftPagination: Boolean, isKeepScreenOn: Boolean, isTtsSessionActive: Boolean, isBookmarked: Boolean, @@ -101,6 +105,7 @@ internal fun PdfTopBar( onShowCustomizeTools: () -> Unit, onShowOcrLanguage: () -> Unit, onShowVisualOptions: () -> Unit, + onShowScreenOrientation: () -> Unit, onShowSlider: () -> Unit, onShowToc: () -> Unit, onSearchClick: () -> Unit, @@ -114,6 +119,7 @@ internal fun PdfTopBar( tapToNavigateEnabled: Boolean, onToggleTapToNavigate: () -> Unit, onChangeDisplayMode: (DisplayMode) -> Unit, + onSetRightToLeftPagination: (Boolean) -> Unit, onToggleKeepScreenOn: () -> Unit, onStartAutoScroll: () -> Unit, onShowTtsSettings: () -> Unit, @@ -262,6 +268,13 @@ internal fun PdfTopBar( ) { Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( + text = stringResource(R.string.menu_screen_orientation), + description = stringResource(R.string.visual_options_screen_orientation_desc), + onClick = onShowScreenOrientation + ) { + Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } else -> Unit } } @@ -281,11 +294,17 @@ internal fun PdfTopBar( Box { var showMoreMenu by remember { mutableStateOf(false) } var showHiddenToolsExpanded by remember { mutableStateOf(false) } + var showReadingModeExpanded by remember { mutableStateOf(false) } + var showTtsSettingsExpanded by remember { mutableStateOf(false) } + var showFileActionsExpanded by remember { mutableStateOf(false) } TooltipIconButton( text = stringResource(R.string.tooltip_more_options), description = stringResource(R.string.tooltip_more_options_desc), onClick = { showHiddenToolsExpanded = false + showReadingModeExpanded = false + showTtsSettingsExpanded = false + showFileActionsExpanded = false showMoreMenu = true }) { Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options)) @@ -295,6 +314,9 @@ internal fun PdfTopBar( expanded = showMoreMenu, onDismissRequest = { showHiddenToolsExpanded = false + showReadingModeExpanded = false + showTtsSettingsExpanded = false + showFileActionsExpanded = false showMoreMenu = false } ) { @@ -340,7 +362,8 @@ internal fun PdfTopBar( onToggleHighlights = onToggleHighlights, onShowAiHub = onShowAiHub, onToggleEditMode = onToggleEditMode, - onToggleTts = onToggleTts + onToggleTts = onToggleTts, + onShowScreenOrientation = onShowScreenOrientation ) } } @@ -366,18 +389,52 @@ internal fun PdfTopBar( if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, - enabled = !isTtsSessionActive, - onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false }, - trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsSessionActive, - onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false }, - trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } + text = { Text(stringResource(R.string.menu_change_reading_mode)) }, + onClick = { showReadingModeExpanded = !showReadingModeExpanded }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) + ) + } ) + if (showReadingModeExpanded) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + enabled = !isTtsSessionActive, + onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false }, + trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, + enabled = !isTtsSessionActive, + onClick = { + onSetRightToLeftPagination(false) + onChangeDisplayMode(DisplayMode.PAGINATION) + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) { + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, + enabled = !isTtsSessionActive, + onClick = { + onSetRightToLeftPagination(true) + onChangeDisplayMode(DisplayMode.PAGINATION) + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) { + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) + } + } + ) + } HorizontalDivider() } @@ -419,24 +476,41 @@ internal fun PdfTopBar( HorizontalDivider() } - if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { + val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) + val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) + if (showTtsVoiceSettings || showTtsReplacements) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - enabled = !isTtsSessionActive, - onClick = { showMoreMenu = false; onShowTtsSettings() }, - leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + text = { Text(stringResource(R.string.menu_tts_settings)) }, + onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) + ) + } ) + if (showTtsSettingsExpanded) { + if (showTtsVoiceSettings) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsSessionActive, + onClick = { showMoreMenu = false; onShowTtsSettings() }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + if (showTtsReplacements) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { showMoreMenu = false; onShowTtsReplacements() }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + } HorizontalDivider() } - if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, - onClick = { showMoreMenu = false; onShowTtsReplacements() }, - leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } - ) - } - if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { DropdownMenuItem( text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, @@ -462,7 +536,7 @@ internal fun PdfTopBar( if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) { DropdownMenuItem( - text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) }, + text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) }, enabled = isPdfDocumentLoaded && !isReflowingThisBook, onClick = { showMoreMenu = false; onReflowAction() }, leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) } @@ -470,28 +544,45 @@ internal fun PdfTopBar( HorizontalDivider() } - if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) { + val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) + val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name) + val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name) + if (showShareAction || showSaveCopyAction || showPrintAction) { DropdownMenuItem( - text = { Text(stringResource(R.string.action_share)) }, - onClick = { showMoreMenu = false; onShare() }, - leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } - ) - } - - if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_copy_to_device)) }, - onClick = { showMoreMenu = false; onSaveCopy() }, - leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) } - ) - } - - if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_print)) }, - onClick = { showMoreMenu = false; onPrint() }, - leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) } + text = { Text(stringResource(R.string.menu_share_save_print)) }, + onClick = { showFileActionsExpanded = !showFileActionsExpanded }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f) + ) + } ) + if (showFileActionsExpanded) { + if (showShareAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_share)) }, + onClick = { showMoreMenu = false; onShare() }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } + ) + } + if (showSaveCopyAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_save_copy_to_device)) }, + onClick = { showMoreMenu = false; onSaveCopy() }, + leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) } + ) + } + if (showPrintAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_print)) }, + onClick = { showMoreMenu = false; onPrint() }, + leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + } } } } @@ -499,7 +590,7 @@ internal fun PdfTopBar( } if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) { LazyRow( - modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + modifier = Modifier.fillMaxWidth().height(PdfTabStripHeight).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), verticalAlignment = Alignment.Bottom ) { items(openTabs, key = { it.bookId }) { tab -> @@ -509,7 +600,7 @@ internal fun PdfTopBar( Row( modifier = Modifier - .height(if (isSelected) 44.dp else 36.dp) + .height(if (isSelected) PdfTabStripHeight else 36.dp) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .background(bgColor) .clickable { onTabClick(tab.bookId) } @@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem( onToggleHighlights: () -> Unit, onShowAiHub: () -> Unit, onToggleEditMode: () -> Unit, - onToggleTts: () -> Unit + onToggleTts: () -> Unit, + onShowScreenOrientation: () -> Unit ) { val enabled = when (tool) { PdfReaderTool.SLIDER, @@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem( PdfReaderTool.AI_FEATURES -> onShowAiHub() PdfReaderTool.EDIT_MODE -> onToggleEditMode() PdfReaderTool.TTS_CONTROLS -> onToggleTts() + PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation() else -> Unit } }, @@ -606,6 +699,7 @@ private fun HiddenPdfToolMenuItem( PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp)) PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp)) } } @@ -766,6 +860,8 @@ fun PdfBottomBar( onShowAiHub: () -> Unit, onToggleEditMode: () -> Unit, onToggleTts: () -> Unit, + onShowScreenOrientation: () -> Unit, + showBubbleZoom: Boolean, isBubbleZoomModeActive: Boolean, onToggleBubbleZoom: () -> Unit ) { @@ -868,11 +964,18 @@ fun PdfBottomBar( ) { Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton( + text = stringResource(R.string.menu_screen_orientation), + description = stringResource(R.string.visual_options_screen_orientation_desc), + onClick = onShowScreenOrientation + ) { + Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } else -> Unit } } - if (BuildConfig.FLAVOR != "oss") { + if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) { TooltipIconButton( text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom), description = stringResource(R.string.desc_toggle_smart_comic_zoom), diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 65cc6dd..0d42531 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -63,7 +63,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size @@ -101,7 +99,6 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.font.FontWeight @@ -116,6 +113,8 @@ import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage +import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx +import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState { private data class PdfPageLayout( val index: Int, - val y: Float, - val height: Float, - val width: Float, + val yPx: Int, + val heightPx: Int, + val widthPx: Int, val widthDp: Dp, val heightDp: Dp +) { + val y: Float + get() = yPx.toFloat() + + val height: Float + get() = heightPx.toFloat() + + val width: Float + get() = widthPx.toFloat() +} + +private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) { + val y: Float + get() = yPx.toFloat() + + val width: Float + get() = widthPx.toFloat() + + val height: Float + get() = heightPx.toFloat() +} + +internal data class PdfLockedOrientationResetCamera( + val zoom: Float, + val panX: Float, + val panY: Float ) -private data class DividerLayout(val y: Float, val width: Float, val height: Float) +internal fun calculateLockedOrientationResetCamera( + pageTopY: Float, + totalDocHeight: Float, + screenWidth: Float, + screenHeight: Float, + headerHeightPx: Float, + footerHeightPx: Float, + fitZoom: Float +): PdfLockedOrientationResetCamera { + val targetPanY = headerHeightPx - (pageTopY * fitZoom) + val zoomedDocHeight = totalDocHeight * fitZoom + val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) { + headerHeightPx + } else { + (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) + } + val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx) + + val zoomedDocWidth = screenWidth * fitZoom + val targetPanX = if (zoomedDocWidth < screenWidth) { + (screenWidth - zoomedDocWidth) / 2f + } else { + 0f + } + + return PdfLockedOrientationResetCamera( + zoom = fitZoom, + panX = targetPanX, + panY = finalPanY + ) +} @Suppress("UnusedVariable") @SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber") @@ -192,6 +247,7 @@ internal fun PdfVerticalReader( modifier: Modifier = Modifier, state: VerticalPdfReaderState, pdfDocument: StableHolder, + documentKey: String, activeTheme: com.aryan.reader.ReaderTheme, activeTextureAlpha: Float = 0.55f, excludeImages: Boolean = false, @@ -230,6 +286,7 @@ internal fun PdfVerticalReader( selectedTool: InkType, richTextController: RichTextController? = null, textBoxes: List = emptyList(), + textBoxesByPage: Map> = emptyMap(), selectedTextBoxId: String? = null, onTextBoxChange: (PdfTextBox) -> Unit = {}, onTextBoxSelect: (String) -> Unit = {}, @@ -245,6 +302,7 @@ internal fun PdfVerticalReader( stylusButtonHovering: Boolean = false, isHighlighterSnapEnabled: Boolean = false, userHighlights: List = emptyList(), + userHighlightsByPage: Map> = emptyMap(), onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightDelete: (String) -> Unit = {}, @@ -258,9 +316,10 @@ internal fun PdfVerticalReader( onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, resetZoomTrigger: Long = 0L, isBubbleZoomModeActive: Boolean = false, + showPageGap: Boolean = true, + showPageNumberOverlay: Boolean = true, onDetectBubbles: suspend (Int, Bitmap) -> List = { _, _ -> emptyList() } ) { - SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } DisposableEffect(state) { onDispose { state.scrollToPageHandler = null @@ -273,6 +332,13 @@ internal fun PdfVerticalReader( var globalEraserPosition by remember { mutableStateOf(null) } var isStylusEraserOverride by remember { mutableStateOf(false) } val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" + val verticalPageBackgroundColor = remember(activeTheme) { + when (activeTheme.id) { + "no_theme", "system" -> Color.White + "reverse" -> Color.Black + else -> activeTheme.backgroundColor + } + } BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { val imeInsets = WindowInsets.ime val density = LocalDensity.current @@ -282,6 +348,12 @@ internal fun PdfVerticalReader( val ratios = pageAspectRatios.item val bookmarkSet = bookmarks.item + val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) { + textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } } + } + val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) { + userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } } + } val scope = rememberCoroutineScope() @@ -294,56 +366,37 @@ internal fun PdfVerticalReader( val headerHeightPx = with(density) { headerHeight.toPx() } val footerHeightPx = with(density) { footerHeight.toPx() } - val dividerHeightDp = 8.dp + val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp) val dividerHeightPx = with(density) { dividerHeightDp.toPx() } + val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0) var isFlinging by remember { mutableStateOf(false) } var isFastFlinging by remember { mutableStateOf(false) } var isInteracting by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) } - val layoutState = remember(ratios, screenWidth, screenHeight, density) { + val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) { data class LayoutResult(val pages: List, val totalHeight: Float) - var currentY = 0.0 + val verticalLayout = calculatePdfVerticalPageLayoutPx( + pageAspectRatios = ratios, + viewportWidthPx = constraints.maxWidth, + viewportHeightPx = constraints.maxHeight, + pageGapPx = dividerHeightPxInt + ) - if (ratios.size == 1) { - val ratio = ratios[0] - val safeRatio = if (ratio <= 0f) 1f else ratio - val pageHeight = screenWidth / safeRatio - if (pageHeight < screenHeight) { - currentY = ((screenHeight - pageHeight) / 2f).toDouble() - } + val pages = verticalLayout.pages.map { page -> + PdfPageLayout( + index = page.pageIndex, + yPx = page.topPx, + heightPx = page.heightPx, + widthPx = page.widthPx, + widthDp = with(density) { page.widthPx.toDp() }, + heightDp = with(density) { page.heightPx.toDp() } + ) } - val pages = ratios.mapIndexed { index, ratio -> - val safeRatio = if (ratio <= 0f) 1f else ratio - val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble() - val pageHeight = pageHeightDouble.toFloat() - - val info = PdfPageLayout( - index = index, - y = currentY.toFloat(), - height = pageHeight, - width = screenWidth, - widthDp = with(density) { screenWidth.toDp() }, - heightDp = with(density) { pageHeight.toDp() }) - - currentY += pageHeightDouble - if (index < ratios.lastIndex) { - currentY += dividerHeightPx - } - info - } - - val totalH = if (pages.isNotEmpty()) { - val last = pages.last() - last.y + last.height - } else { - 0f - } - - LayoutResult(pages, totalH) + LayoutResult(pages, verticalLayout.totalHeightPx.toFloat()) } val layoutInfo = layoutState.pages @@ -375,11 +428,17 @@ internal fun PdfVerticalReader( var isResizing by remember { mutableStateOf(false) } var previousScreenWidth by remember { mutableFloatStateOf(0f) } var previousScreenHeight by remember { mutableFloatStateOf(0f) } + var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) } val targetPageDuringResize = remember { mutableIntStateOf(-1) } if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) { if (previousScreenWidth > 0f) { + val previousWasLandscape = previousScreenWidth > previousScreenHeight + val currentIsLandscape = screenWidth > screenHeight isResizing = true + if (isScrollLocked && previousWasLandscape != currentIsLandscape) { + lockedOrientationChangedDuringResize = true + } if (targetPageDuringResize.intValue == -1) { targetPageDuringResize.intValue = state.currentPage } @@ -424,7 +483,45 @@ internal fun PdfVerticalReader( } LaunchedEffect(layoutState.pages) { - if (!isInitialLayout && !isScrollLocked) { + if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) { + val targetPageIdx = if (targetPageDuringResize.intValue != -1) { + targetPageDuringResize.intValue + } else { + state.currentPage + } + + val newLayout = layoutState.pages + val pageLayout = newLayout.getOrNull(targetPageIdx) + + if (pageLayout != null) { + val resetCamera = calculateLockedOrientationResetCamera( + pageTopY = pageLayout.y, + totalDocHeight = layoutState.totalHeight, + screenWidth = screenWidth, + screenHeight = screenHeight, + headerHeightPx = headerHeightPx, + footerHeightPx = footerHeightPx, + fitZoom = fitZoom + ) + + panXAnimatable.updateBounds(null, null) + panYAnimatable.updateBounds(null, null) + + coroutineScope { + launch { zoomAnimatable.snapTo(resetCamera.zoom) } + launch { panXAnimatable.snapTo(resetCamera.panX) } + launch { panYAnimatable.snapTo(resetCamera.panY) } + } + + state.currentPage = targetPageIdx + hasRestoredLockedState = true + onZoomChange(resetCamera.zoom) + onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY)) + Timber.tag("PdfLockDiagnostic").i( + "Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx" + ) + } + } else if (!isInitialLayout && !isScrollLocked) { val targetPageIdx = if (targetPageDuringResize.intValue != -1) { targetPageDuringResize.intValue } else { @@ -466,6 +563,7 @@ internal fun PdfVerticalReader( if (!isInitialLayout) { delay(50) isResizing = false + lockedOrientationChangedDuringResize = false targetPageDuringResize.intValue = -1 } isInitialLayout = false @@ -1080,8 +1178,9 @@ internal fun PdfVerticalReader( Box( modifier = Modifier .fillMaxSize() + .background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor) .then(globalDrawingModifier) - .pointerInput(isEditMode, selectedTool, isStylusOnlyMode) { + .pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) { Timber.tag("PdfTouchDebug").v( "VerticalReader: TapPointerInput init. isEditMode=$isEditMode" ) @@ -1101,8 +1200,10 @@ internal fun PdfVerticalReader( onPageClick() } }, onDoubleTap = { offset -> - Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected") - onDoubleTapToZoom(offset) + if (!isScrollLocked) { + Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected") + onDoubleTapToZoom(offset) + } }) } .pointerInput( @@ -1428,11 +1529,16 @@ internal fun PdfVerticalReader( } val cached = cachedVisiblePages.value - val indicesMatch = cached.size == finalPages.size && cached.indices.all { - cached[it].index == finalPages[it].index + val layoutMatches = cached.size == finalPages.size && cached.indices.all { + val cachedPage = cached[it] + val newPage = finalPages[it] + cachedPage.index == newPage.index && + cachedPage.yPx == newPage.yPx && + cachedPage.heightPx == newPage.heightPx && + cachedPage.widthPx == newPage.widthPx } - if (!indicesMatch) { + if (!layoutMatches) { cachedVisiblePages.value = finalPages Timber.tag("PdfDrawPerf").d( "Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})" @@ -1472,20 +1578,13 @@ internal fun PdfVerticalReader( Layout( content = { visiblePages.forEach { page -> - key(page.index) { + key(documentKey, page.index) { val isBookmarked by remember(bookmarkSet, page.index) { derivedStateOf { bookmarkSet.any { it.pageIndex == page.index } } } - SideEffect { - if (page.index == state.currentPage) { - Timber.tag("PdfDrawPerf") - .v("VERTICAL READER: Emitting Page ${page.index}") - } - } - val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) { { val zoom = zoomAnimatable.value @@ -1579,6 +1678,7 @@ internal fun PdfVerticalReader( { text: String -> onSearchText(text) } } + val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom) val onDoubleTapLambda = remember(page, screenWidth, screenHeight) { { localOffset: Offset -> Timber.tag("PdfZoomDebug").d( @@ -1592,7 +1692,7 @@ internal fun PdfVerticalReader( val screenX = contentX * currentZ + panX val screenY = contentY * currentZ + panY Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log - onDoubleTapToZoom(Offset(screenX, screenY)) + currentOnDoubleTapToZoom(Offset(screenX, screenY)) } } @@ -1661,32 +1761,10 @@ internal fun PdfVerticalReader( Box(modifier = Modifier .layoutId(page) - .graphicsLayer { - val z = zoomAnimatable.value - val px = panXAnimatable.value - val py = panYAnimatable.value - - scaleX = z - scaleY = z - translationX = px - translationY = page.y * (z - 1f) + py - transformOrigin = TransformOrigin(0f, 0f) - - if (page.index < 2 && z > 1.1f) { - Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})") - } - } - .clipToBounds() - .onGloballyPositioned { coordinates -> - if (page.index == 0) { - val pos = coordinates.positionInWindow() - Timber.d( - "Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}" - ) - } - }) { + ) { PdfPageComposable( pdfDocument = pdfDocument, + documentKey = documentKey, pageIndex = page.index, virtualPage = virtualPage, totalPages = totalPages, @@ -1716,6 +1794,8 @@ internal fun PdfVerticalReader( isZoomEnabled = false, isScrolling = isDragging || (isFlinging && isFastFlinging), isVerticalScroll = true, + showPageNumberOverlay = showPageNumberOverlay, + isScrollLocked = isScrollLocked, visualScaleProvider = currentScaleProvider, onDoubleTap = onDoubleTapLambda, clearSelectionTrigger = selectionClearTrigger, @@ -1734,11 +1814,11 @@ internal fun PdfVerticalReader( isStylusOnlyMode = isStylusOnlyMode, stylusButtonHovering = stylusButtonHovering, isAutoScrollPlaying = isAutoScrollPlaying, - textBoxes = textBoxes.filter { it.pageIndex == page.index }, + textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(), selectedTextBoxId = selectedTextBoxId, onTextBoxChange = onTextBoxChange, onTextBoxSelect = onTextBoxSelect, - userHighlights = userHighlights.filter { it.pageIndex == page.index }, + userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(), onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, @@ -1852,26 +1932,17 @@ internal fun PdfVerticalReader( ) } - if (page.index < totalPages - 1) { - val dividerY = page.y + page.height + if (page.index < totalPages - 1 && dividerHeightPxInt > 0) { + val dividerYPx = page.yPx + page.heightPx Box( modifier = Modifier .layoutId( DividerLayout( - dividerY, page.width, dividerHeightPx + yPx = dividerYPx, + widthPx = page.widthPx, + heightPx = dividerHeightPxInt ) ) - .graphicsLayer { - val z = zoomAnimatable.value - val px = panXAnimatable.value - val py = panYAnimatable.value - - scaleX = z - scaleY = z - translationX = px - translationY = dividerY * (z - 1f) + py - transformOrigin = TransformOrigin(0f, 0f) - } .background( MaterialTheme.colorScheme.surfaceVariant )) @@ -1881,6 +1952,15 @@ internal fun PdfVerticalReader( }, modifier = Modifier .fillMaxSize() + .graphicsLayer { + val z = zoomAnimatable.value + + scaleX = z + scaleY = z + translationX = panXAnimatable.value + translationY = panYAnimatable.value + transformOrigin = TransformOrigin(0f, 0f) + } .onGloballyPositioned { _ -> }) { measurables, constraints -> val layoutStart = System.nanoTime() Timber.tag("PdfDrawPerf") @@ -1891,19 +1971,19 @@ internal fun PdfVerticalReader( is PdfPageLayout -> { val placeable = measurable.measure( Constraints.fixed( - id.width.roundToInt(), id.height.roundToInt() + id.widthPx, id.heightPx ) ) - placeable.place(0, id.y.roundToInt()) + placeable.place(0, id.yPx) } is DividerLayout -> { val placeable = measurable.measure( Constraints.fixed( - id.width.roundToInt(), id.height.roundToInt() + id.widthPx, id.heightPx ) ) - placeable.place(0, id.y.roundToInt()) + placeable.place(0, id.yPx) } } } 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 3d4ef79..43e3995 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -120,7 +120,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -198,13 +197,13 @@ import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiFeature import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiHubBottomSheet -import com.aryan.reader.BannerMessage import com.aryan.reader.BuildConfig -import com.aryan.reader.CustomTopBanner import com.aryan.reader.FileType import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.MainViewModel import com.aryan.reader.R +import com.aryan.reader.ReaderScreenOrientationEffect +import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderThemePanel import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult @@ -225,6 +224,8 @@ import com.aryan.reader.callByokGeminiInlineAi import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency +import com.aryan.reader.loadReaderScreenOrientationMode +import com.aryan.reader.loadPdfRightToLeftPagination import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.data.AnnotationSettingsRepository @@ -240,7 +241,10 @@ import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency +import com.aryan.reader.saveReaderScreenOrientationMode +import com.aryan.reader.savePdfRightToLeftPagination import com.aryan.reader.saveTtsReplacementPreferences +import com.aryan.reader.scaledToCanvasLimit import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer @@ -288,6 +292,36 @@ internal fun resolveEraserStrokeWidth( eraserToolThickness: Float ): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness +internal fun canUsePdfSidecarsForBook( + activeBookId: String?, + loadedSidecarBookId: String?, + areSidecarsLoaded: Boolean +): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId + +internal fun currentPageScaleAfterPdfPageChange( + displayMode: DisplayMode, + isScrollLocked: Boolean, + lockedState: Triple?, + currentActiveScale: Float +): Float { + return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) { + lockedState?.first ?: currentActiveScale + } else { + 1f + } +} + +internal fun activePdfCameraAfterLockPreferenceLoad( + isScrollLocked: Boolean, + lockedState: Triple? +): Pair { + return if (isScrollLocked && lockedState != null) { + lockedState.first to Offset(lockedState.second, lockedState.third) + } else { + 1f to Offset.Zero + } +} + @Suppress("KotlinConstantConditions") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @ExperimentalMaterial3Api @@ -305,7 +339,6 @@ fun PdfViewerScreen( onNavigateToPro: () -> Unit, viewModel: MainViewModel ) { - SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") } val context = LocalContext.current LaunchedEffect(Unit) { PdfFontCache.init(context.assets) @@ -338,7 +371,12 @@ fun PdfViewerScreen( var pageAspectRatios by remember { mutableStateOf>(emptyList()) } var showBars by rememberSaveable { mutableStateOf(true) } var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) } + var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) } + var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } + var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } + var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) } + var showScreenOrientationSheet by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) } var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } @@ -349,6 +387,7 @@ fun PdfViewerScreen( var showPasswordDialog by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) } LocalView.current + ReaderScreenOrientationEffect(screenOrientationMode) var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) } var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) } @@ -397,6 +436,7 @@ fun PdfViewerScreen( val uiState by viewModel.uiState.collectAsState() val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri val effectiveFileType = uiState.selectedFileType ?: FileType.PDF + val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7 var showNewTabSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) @@ -404,6 +444,7 @@ fun PdfViewerScreen( val isTabsEnabled = uiState.isTabsEnabled val openTabs = uiState.openTabs val activeTabBookId = uiState.activeTabBookId + val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF val originalFileName by remember(uiState.recentFiles, effectivePdfUri) { derivedStateOf { uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName @@ -412,6 +453,7 @@ fun PdfViewerScreen( } var currentBookId by remember { mutableStateOf(null) } val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString() + val activeDocumentRenderKey = currentBookId ?: effectivePdfUri.toString() var documentMetadataTitle by remember { mutableStateOf(null) } val view = LocalView.current var isDockDragging by remember { mutableStateOf(false) } @@ -425,8 +467,16 @@ fun PdfViewerScreen( } LaunchedEffect(bookId) { - isScrollLocked = loadPdfScrollLocked(context, bookId) - lockedState = loadPdfLockedState(context, bookId) + val savedIsScrollLocked = loadPdfScrollLocked(context, bookId) + val savedLockedState = loadPdfLockedState(context, bookId) + val activeCamera = activePdfCameraAfterLockPreferenceLoad( + isScrollLocked = savedIsScrollLocked, + lockedState = savedLockedState + ) + isScrollLocked = savedIsScrollLocked + lockedState = savedLockedState + currentActiveScale = activeCamera.first + currentActiveOffset = activeCamera.second } var isAutoScrollModeActive by remember { mutableStateOf(false) } @@ -496,9 +546,8 @@ fun PdfViewerScreen( val customFonts by viewModel.customFonts.collectAsState() - var bannerMessage by remember { mutableStateOf(null) } - fun showBanner(message: String, isError: Boolean = false) { - bannerMessage = BannerMessage(message, isError = isError) + fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { + viewModel.showBanner(message, isError, isPersistent) } val onOcrStateChange: (Boolean) -> Unit = {} @@ -638,6 +687,13 @@ fun PdfViewerScreen( var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) } val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState() + LaunchedEffect(isComicFile) { + if (!isComicFile) { + isBubbleZoomModeActive = false + showBubbleZoomDownloadDialog = false + } + } + var dockLocation by remember { mutableStateOf(initialDockLocation) } var dockOffset by remember { mutableStateOf(initialDockOffset) } var snapPreviewLocation by remember { mutableStateOf(null) } @@ -725,7 +781,8 @@ fun PdfViewerScreen( val targetTopOverlayInset = remember( showStandardBars, systemUiMode, - statusBarHeightDp + statusBarHeightDp, + isPdfTabStripVisible ) { if (!showStandardBars) { 0.dp @@ -737,6 +794,9 @@ fun PdfViewerScreen( if (isStatusBarVisible) { inset += statusBarHeightDp } + if (isPdfTabStripVisible) { + inset += PdfTabStripHeight + } inset } } @@ -775,13 +835,14 @@ fun PdfViewerScreen( LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) } - LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) { + LaunchedEffect(bookId, currentActiveScale, currentActiveOffset, isScrollLocked) { if (isScrollLocked) { + val requestedCamera = currentActiveScale to currentActiveOffset delay(500) - Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}") + Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=${requestedCamera.first} | X=${requestedCamera.second.x} | Y=${requestedCamera.second.y}") - lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) - savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + lockedState = Triple(requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y) + savePdfLockedState(context, bookId, requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y) } } @@ -800,14 +861,6 @@ fun PdfViewerScreen( errorMessage?.let { showBanner(it, isError = true) } } - LaunchedEffect(bannerMessage) { - val message = bannerMessage ?: return@LaunchedEffect - if (!message.isPersistent) { - delay(2500L) - bannerMessage = null - } - } - val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val toolSettings by annotationSettingsRepo.settings.collectAsState() var showToolSettings by rememberSaveable { mutableStateOf(false) } @@ -861,6 +914,7 @@ fun PdfViewerScreen( var lastEraserPoint by remember { mutableStateOf(null) } var areAnnotationsLoaded by remember { mutableStateOf(false) } + var loadedSidecarBookId by remember { mutableStateOf(null) } val richTextRepository = remember(context) { PdfRichTextRepository(context) } val richTextController = remember(currentBookId) { @@ -936,16 +990,10 @@ fun PdfViewerScreen( } fun buildSpeechBubblePrefetchOrder(): List { - if (totalDisplayPages <= 0) return emptyList() - val ordered = LinkedHashSet() - ordered += currentPage.coerceIn(0, totalDisplayPages - 1) - for (distance in 1 until totalDisplayPages) { - val next = currentPage + distance - val previous = currentPage - distance - if (next in 0 until totalDisplayPages) ordered += next - if (previous in 0 until totalDisplayPages) ordered += previous - } - return ordered.toList() + return buildPdfBubblePrefetchOrder( + currentPage = currentPage, + totalPages = totalDisplayPages + ) } suspend fun detectSpeechBubblesForPage( @@ -1125,39 +1173,80 @@ fun PdfViewerScreen( val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } } + val sidecarsReadyForCurrentBook = + canUsePdfSidecarsForBook(currentBookId, loadedSidecarBookId, areAnnotationsLoaded) + val textBoxesSnapshot by remember { derivedStateOf { textBoxes.toList() } } + val userHighlightsSnapshot by remember { derivedStateOf { userHighlights.toList() } } + val visibleAllAnnotations = if (sidecarsReadyForCurrentBook) allAnnotations else emptyMap() + val visibleTextBoxes = if (sidecarsReadyForCurrentBook) textBoxesSnapshot else emptyList() + val visibleUserHighlights = if (sidecarsReadyForCurrentBook) userHighlightsSnapshot else emptyList() + val visibleTextBoxesByPage = remember(sidecarsReadyForCurrentBook, textBoxesSnapshot) { + if (sidecarsReadyForCurrentBook) { + textBoxesSnapshot.groupBy { it.pageIndex } + } else { + emptyMap() + } + } + val visibleUserHighlightsByPage = remember(sidecarsReadyForCurrentBook, userHighlightsSnapshot) { + if (sidecarsReadyForCurrentBook) { + userHighlightsSnapshot.groupBy { it.pageIndex } + } else { + emptyMap() + } + } + val currentAnnotations by rememberUpdatedState(allAnnotations) - val currentTextBoxes by rememberUpdatedState(textBoxes.toList()) - val currentHighlights by rememberUpdatedState(userHighlights.toList()) + val currentTextBoxes by rememberUpdatedState(textBoxesSnapshot) + val currentHighlights by rememberUpdatedState(userHighlightsSnapshot) + val currentLoadedSidecarBookId by rememberUpdatedState(loadedSidecarBookId) + val currentAreAnnotationsLoaded by rememberUpdatedState(areAnnotationsLoaded) val currentBookmarks by rememberUpdatedState(bookmarks) val currentTotalPages by rememberUpdatedState(totalDisplayPages) val currentPageState by rememberUpdatedState(currentPage) val currentPendingPage by rememberUpdatedState(pendingRestorePage) + val currentVisibleAllAnnotations by rememberUpdatedState(visibleAllAnnotations) val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) { { force: Boolean -> + val bookIdSnapshot = currentBookId + val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId + val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook( + bookIdSnapshot, + loadedSidecarBookIdSnapshot, + currentAreAnnotationsLoaded + ) + val isDocumentReadySnapshot = isDocumentReady + val initialScrollDoneSnapshot = initialScrollDone + val annotsSnapshot = currentAnnotations + val boxesSnapshot = currentTextBoxes + val highlightsSnapshot = currentHighlights + val bookmarksSnapshot = currentBookmarks + val totalPagesSnapshot = currentTotalPages + val currentPageSnapshot = currentPageState + val pendingPageSnapshot = currentPendingPage viewModel.viewModelScope.launch { - val bookId = currentBookId ?: return@launch + val bookId = bookIdSnapshot ?: return@launch - if (!isDocumentReady && !force) { + if (!isDocumentReadySnapshot && !force) { Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.") return@launch } - val annots = currentAnnotations - val boxes = currentTextBoxes - val highlights = currentHighlights - val bms = currentBookmarks - val totalPgs = currentTotalPages + val annots = annotsSnapshot + val boxes = boxesSnapshot + val highlights = highlightsSnapshot + val bms = bookmarksSnapshot + val totalPgs = totalPagesSnapshot - val restoreTarget = currentPendingPage ?: 0 - val page = if (!initialScrollDone) { - Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)") + val restoreTarget = pendingPageSnapshot ?: 0 + val page = if (!initialScrollDoneSnapshot) { + Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageSnapshot)") restoreTarget } else { - currentPageState + currentPageSnapshot } - Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)") + Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageSnapshot, Target: $restoreTarget, Done: $initialScrollDoneSnapshot)") val annotsHash = annots.hashCode() val boxesHash = boxes.hashCode() @@ -1169,20 +1258,26 @@ fun PdfViewerScreen( withContext(Dispatchers.IO) { @Suppress("VariableNeverRead") var didSave = false - if (force || annotsHash != lastSavedHashes[0]) { - annotationRepository.saveAnnotations(bookId, annots) - lastSavedHashes[0] = annotsHash - didSave = true - } - if (force || boxesHash != lastSavedHashes[1]) { - textBoxRepository.saveTextBoxes(bookId, boxes) - lastSavedHashes[1] = boxesHash - didSave = true - } - if (force || highlightsHash != lastSavedHashes[2]) { - highlightRepository.saveHighlights(bookId, highlights) - lastSavedHashes[2] = highlightsHash - didSave = true + if (canSaveSidecarsSnapshot) { + if (force || annotsHash != lastSavedHashes[0]) { + annotationRepository.saveAnnotations(bookId, annots) + lastSavedHashes[0] = annotsHash + didSave = true + } + if (force || boxesHash != lastSavedHashes[1]) { + textBoxRepository.saveTextBoxes(bookId, boxes) + lastSavedHashes[1] = boxesHash + didSave = true + } + if (force || highlightsHash != lastSavedHashes[2]) { + highlightRepository.saveHighlights(bookId, highlights) + lastSavedHashes[2] = highlightsHash + didSave = true + } + } else { + Timber.tag("PdfTabSync").d( + "Skipping PDF sidecar save for $bookId; loaded sidecars belong to $loadedSidecarBookIdSnapshot" + ) } if (force || bmsHash != lastSavedHashes[3]) { val objectList = bms.map { bookmark -> @@ -1239,18 +1334,19 @@ fun PdfViewerScreen( LaunchedEffect( allAnnotations, - textBoxes.toList(), - userHighlights.toList(), + textBoxesSnapshot, + userHighlightsSnapshot, bookmarks, - currentPage + currentPage, + sidecarsReadyForCurrentBook ) { - if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) { + if (sidecarsReadyForCurrentBook && initialScrollDone) { delay(2000) // Debounce period saveAllData(false) } } - val allAnnotationsProvider = remember { { allAnnotations } } + val allAnnotationsProvider = remember { { currentVisibleAllAnnotations } } LaunchedEffect(Unit) { Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'") @@ -2070,26 +2166,39 @@ fun PdfViewerScreen( } LaunchedEffect(currentBookId) { - if (currentBookId != null) { - val loaded = annotationRepository.loadAnnotations(currentBookId!!) - allAnnotations = loaded - areAnnotationsLoaded = true + val loadingBookId = currentBookId - val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!) - textBoxes.clear() - textBoxes.addAll(loadedBoxes) + areAnnotationsLoaded = false + loadedSidecarBookId = null + allAnnotations = emptyMap() + textBoxes.clear() + userHighlights.clear() + selectedTextBoxId = null + undoStack.clear() + redoStack.clear() + erasedAnnotationsFromStroke.clear() + drawingState.onDrawCancel() - val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!) - userHighlights.clear() - userHighlights.addAll(loadedHighlights) - } + if (loadingBookId == null) return@LaunchedEffect + + val loaded = annotationRepository.loadAnnotations(loadingBookId) + val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId) + val loadedHighlights = highlightRepository.loadHighlights(loadingBookId) + + if (currentBookId != loadingBookId) return@LaunchedEffect + + allAnnotations = loaded + textBoxes.addAll(loadedBoxes) + userHighlights.addAll(loadedHighlights) + loadedSidecarBookId = loadingBookId + areAnnotationsLoaded = true } var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) } - LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) { + LaunchedEffect(pdfDocument, currentBookId, userHighlightsSnapshot, sidecarsReadyForCurrentBook) { val document = pdfDocument ?: return@LaunchedEffect - if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect - val snapshot = userHighlights.toList() + if (!sidecarsReadyForCurrentBook || isRebuildingSyncedHighlightBounds) return@LaunchedEffect + val snapshot = userHighlightsSnapshot if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect isRebuildingSyncedHighlightBounds = true @@ -2116,18 +2225,18 @@ fun PdfViewerScreen( coroutineScope.launch { val currentRichTextLayouts = richTextController?.pageLayouts - Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}") - if (userHighlights.isEmpty()) { + Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}") + if (visibleUserHighlights.isEmpty()) { Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.") } viewModel.savePdfWithAnnotations( sourceUri = effectivePdfUri, destUri = uri, - annotations = allAnnotations, + annotations = visibleAllAnnotations, richTextPageLayouts = currentRichTextLayouts, - textBoxes = textBoxes.toList(), - highlights = userHighlights.toList(), + textBoxes = visibleTextBoxes, + highlights = visibleUserHighlights, bookId = currentBookId!! ) } @@ -2879,6 +2988,17 @@ fun PdfViewerScreen( isDocumentReady = false errorMessage = null documentMetadataTitle = null + currentBookId = null + areAnnotationsLoaded = false + loadedSidecarBookId = null + allAnnotations = emptyMap() + textBoxes.clear() + userHighlights.clear() + selectedTextBoxId = null + undoStack.clear() + redoStack.clear() + erasedAnnotationsFromStroke.clear() + drawingState.onDrawCancel() if (showPasswordDialog) isPasswordError = false @@ -2932,12 +3052,13 @@ fun PdfViewerScreen( withContext(Dispatchers.IO) { Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri") - if (pdfUri.scheme != "opds-pse") { + val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF + if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) { currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") } - val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore) + val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore) if (!isActive) { doc.close() @@ -3027,7 +3148,7 @@ fun PdfViewerScreen( currentBookId!!, DocumentCacheItem( doc = doc, - pfd = currentPfdOpened!!, + pfd = currentPfdOpened, totalPages = pagesCount, pageAspectRatios = ratios, flatTableOfContents = flatTableOfContents @@ -3094,8 +3215,8 @@ fun PdfViewerScreen( isLoadingDocument = false } } else { - Timber.e(e, "Error loading PDF document") - errorMessage = "Error loading PDF: ${e.localizedMessage}" + Timber.e(e, "Error loading fixed-layout document") + errorMessage = "Error loading document: ${e.localizedMessage}" isLoadingDocument = false } if (pdfDocument == null) { @@ -3132,8 +3253,14 @@ fun PdfViewerScreen( summarizationResult = null } - LaunchedEffect(pagerState.currentPage) { - currentPageScale = 1f + LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) { + val nextPageScale = currentPageScaleAfterPdfPageChange( + displayMode = displayMode, + isScrollLocked = isScrollLocked, + lockedState = lockedState, + currentActiveScale = currentActiveScale + ) + currentPageScale = nextPageScale ocrUsedForCurrentPageTts = false } @@ -3172,7 +3299,8 @@ fun PdfViewerScreen( LaunchedEffect(effectivePdfUri, currentBookId, totalPages) { if (currentBookId == null || totalPages == 0) return@LaunchedEffect if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect - if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect + val selectedDocumentType = uiState.selectedFileType ?: return@LaunchedEffect + if (selectedDocumentType != FileType.PDF && selectedDocumentType != FileType.PPTX) return@LaunchedEffect withContext(Dispatchers.IO) { val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!) @@ -3183,6 +3311,7 @@ fun PdfViewerScreen( isBackgroundIndexing = true var bgPfd: ParcelFileDescriptor? = null var bgDoc: PdfDocumentKt? = null + var genericDoc: ReaderDocument? = null try { val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!) @@ -3199,17 +3328,18 @@ fun PdfViewerScreen( "Indexer: Starting background indexing for ${totalPages - existingPages.size} pages." ) - bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") - val openedBgPfd = bgPfd - if (openedBgPfd != null) { + val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) } + val totalToDo = pagesToIndex.size + var completed = 0 + + if (selectedDocumentType == FileType.PDF) { + bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") + val openedBgPfd = bgPfd + if (openedBgPfd == null) return@withContext bgDoc = PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(openedBgPfd, documentPassword) } - val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) } - val totalToDo = pagesToIndex.size - var completed = 0 - for (pageIndex in pagesToIndex) { if (!isActive) break @@ -3223,6 +3353,37 @@ fun PdfViewerScreen( Timber.e(e, "Indexer: Failed on page $pageIndex") } + completed++ + if (completed % 5 == 0 || completed == totalToDo) { + val totalIndexedSoFar = initialIndexedCount + completed + backgroundIndexingProgress = + totalIndexedSoFar.toFloat() / totalPages.toFloat() + } + } + } else { + val openedGenericDoc = DocumentFactory.loadDocument( + context = context, + uri = effectivePdfUri, + type = selectedDocumentType, + password = null, + pdfiumCore = pdfiumCore + ) + genericDoc = openedGenericDoc + + for (pageIndex in pagesToIndex) { + if (!isActive) break + + try { + pdfTextRepository.indexReaderPage( + bookId = currentBookId!!, + document = openedGenericDoc, + pageIndex = pageIndex, + onOcrModelDownloading = { isOcrModelDownloading = true } + ) + } catch (e: Exception) { + Timber.e(e, "Indexer: Failed on page $pageIndex") + } + completed++ if (completed % 5 == 0 || completed == totalToDo) { val totalIndexedSoFar = initialIndexedCount + completed @@ -3238,6 +3399,7 @@ fun PdfViewerScreen( PdfiumEngineProvider.withPdfium { bgDoc?.close() } + genericDoc?.close() bgPfd?.close() } catch (e: Exception) { Timber.e(e, "Indexer: Cleanup failed") @@ -3510,9 +3672,10 @@ fun PdfViewerScreen( ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { PdfNavigationDrawerContent( pdfDocument = pdfDocument, + documentKey = activeDocumentRenderKey, flatTableOfContents = flatTableOfContents, bookmarks = bookmarks, - userHighlights = userHighlights, + userHighlights = visibleUserHighlights, currentPage = currentPage, totalPages = totalDisplayPages, customHighlightColors = customHighlightColors, @@ -3649,7 +3812,7 @@ fun PdfViewerScreen( } pdfDocument != null && totalPages > 0 -> { - val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) } + val stablePdfDocument = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) } when (displayMode) { DisplayMode.PAGINATION -> { val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset -> @@ -3664,7 +3827,11 @@ fun PdfViewerScreen( tapOffset.x < oneQuarterWidthPx -> { coroutineScope.launch { val targetPage = - (pagerState.currentPage - 1).coerceAtLeast(0) + if (rightToLeftPagination) { + (pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1) + } else { + (pagerState.currentPage - 1).coerceAtLeast(0) + } if (targetPage != pagerState.currentPage) { pagerState.scrollToPage(targetPage) } @@ -3675,9 +3842,13 @@ fun PdfViewerScreen( tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> { coroutineScope.launch { val targetPage = - (pagerState.currentPage + 1).coerceAtMost( - pagerState.pageCount - 1 - ) + if (rightToLeftPagination) { + (pagerState.currentPage - 1).coerceAtLeast(0) + } else { + (pagerState.currentPage + 1).coerceAtMost( + pagerState.pageCount - 1 + ) + } if (targetPage != pagerState.currentPage) { pagerState.scrollToPage(targetPage) } @@ -3694,14 +3865,14 @@ fun PdfViewerScreen( HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), - key = { it }, + key = { page -> "$activeDocumentRenderKey:$page" }, beyondViewportPageCount = dynamicBeyondViewportPageCount, + reverseLayout = rightToLeftPagination, userScrollEnabled = run { - val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null - SideEffect { - Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)") - } - enabled + (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && + !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && + !isPageSliderVisible && + paginationDraggingBoxId == null } ) { pageIndex -> val isVisiblePage = remember(pagerState.currentPage, pageIndex) { @@ -3881,6 +4052,7 @@ fun PdfViewerScreen( PdfPageComposable( pdfDocument = stablePdfDocument, + documentKey = activeDocumentRenderKey, pageIndex = pageIndex, virtualPage = virtualPage, totalPages = totalDisplayPages, @@ -3923,6 +4095,7 @@ fun PdfViewerScreen( isBookmarked = isPageBookmarked, onBookmarkClick = { onToggleBookmark(pageIndex) }, isZoomEnabled = true, + showPageNumberOverlay = showPageNumberOverlay, clearSelectionTrigger = selectionClearTrigger, resetZoomTrigger = resetZoomTrigger, pageAnnotations = pageAnnotationsProvider, @@ -3961,7 +4134,7 @@ fun PdfViewerScreen( onOcrModelDownloading = { isOcrModelDownloading = true }, - userHighlights = userHighlights.filter { it.pageIndex == pageIndex }, + userHighlights = visibleUserHighlightsByPage[pageIndex].orEmpty(), onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, @@ -3980,7 +4153,12 @@ fun PdfViewerScreen( detectSpeechBubblesForPage(sourcePageIndex, bitmap) }, onShowPanelPopup = { bitmapWithRects -> - poppedUpPanelBitmap = bitmapWithRects + val safeBitmap = bitmapWithRects.scaledToCanvasLimit() + if (safeBitmap !== bitmapWithRects && !bitmapWithRects.isRecycled) { + bitmapWithRects.recycle() + } + poppedUpPanelBitmap?.takeUnless { it.isRecycled }?.recycle() + poppedUpPanelBitmap = safeBitmap }, onTwoFingerSwipe = { direction -> coroutineScope.launch { @@ -3999,7 +4177,7 @@ fun PdfViewerScreen( isAutoScrollPlaying = isAutoScrollPlaying, isHighlighterSnapEnabled = isHighlighterSnapEnabled, isEditMode = isDrawingActive, - textBoxes = textBoxes.filter { it.pageIndex == pageIndex }, + textBoxes = visibleTextBoxesByPage[pageIndex].orEmpty(), selectedTextBoxId = selectedTextBoxId, onTextBoxChange = { updatedBox -> val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } @@ -4326,7 +4504,7 @@ fun PdfViewerScreen( Box(modifier = Modifier .fillMaxSize() .clip(RectangleShape)) { - val docHolder = remember(pdfDocument) { + val docHolder = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) } val bookmarksHolder = @@ -4338,6 +4516,7 @@ fun PdfViewerScreen( PdfVerticalReader( state = verticalReaderState, pdfDocument = docHolder, + documentKey = activeDocumentRenderKey, activeTheme = activeTheme, activeTextureAlpha = 1f - globalTextureTransparency, excludeImages = excludeImages, @@ -4364,7 +4543,8 @@ fun PdfViewerScreen( onSearchText = onSearchTextStable, ttsHighlightData = ttsHighlightData, ttsReadingPage = ttsDisplayPageIndex, - userHighlights = userHighlights, + userHighlights = visibleUserHighlights, + userHighlightsByPage = visibleUserHighlightsByPage, onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, @@ -4419,7 +4599,8 @@ fun PdfViewerScreen( isStylusOnlyMode = isStylusOnlyMode, stylusButtonHovering = stylusButtonHovering, isEditMode = isDrawingActive, - textBoxes = textBoxes, + textBoxes = visibleTextBoxes, + textBoxesByPage = visibleTextBoxesByPage, selectedTextBoxId = selectedTextBoxId, onTextBoxChange = { updatedBox -> val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } @@ -4444,6 +4625,8 @@ fun PdfViewerScreen( autoScrollSpeed = autoScrollSpeed * 0.5f, onInteractionListener = onAutoScrollInteraction, lockedState = lockedState, + showPageGap = showVerticalPageGap, + showPageNumberOverlay = showPageNumberOverlay, onZoomAndPanChanged = { newScale, newOffset -> currentActiveScale = newScale currentActiveOffset = newOffset @@ -5030,6 +5213,7 @@ fun PdfViewerScreen( isScrollLocked = isScrollLocked, isEditMode = isEditMode, displayMode = displayMode, + isRightToLeftPagination = rightToLeftPagination, isKeepScreenOn = isKeepScreenOn, isTtsSessionActive = isTtsSessionActive, isBookmarked = isBookmarked, @@ -5084,6 +5268,7 @@ fun PdfViewerScreen( } }, onShowVisualOptions = { showVisualOptionsSheet = true }, + onShowScreenOrientation = { showScreenOrientationSheet = true }, isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading, showAllTextHighlights = showAllTextHighlights, isHighlightingLoading = isHighlightingLoading, @@ -5100,6 +5285,10 @@ fun PdfViewerScreen( saveTapToNavigateSetting(context, tapToNavigateEnabled) }, onChangeDisplayMode = { displayMode = it }, + onSetRightToLeftPagination = { enabled -> + rightToLeftPagination = enabled + savePdfRightToLeftPagination(context, enabled) + }, onToggleKeepScreenOn = { isKeepScreenOn = !isKeepScreenOn saveKeepScreenOn(context, isKeepScreenOn) @@ -5215,7 +5404,11 @@ fun PdfViewerScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.msg_indexing_pages_progress), + text = stringResource( + R.string.msg_indexing_pages_progress, + (backgroundIndexingProgress * 100f).roundToInt() + .coerceIn(0, 100) + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) @@ -5427,6 +5620,8 @@ fun PdfViewerScreen( onShowAiHub = showPdfAiHub, onToggleEditMode = togglePdfEditMode, onToggleTts = togglePdfTts, + onShowScreenOrientation = { showScreenOrientationSheet = true }, + showBubbleZoom = isComicFile, isBubbleZoomModeActive = isBubbleZoomModeActive, onToggleBubbleZoom = { if (isOss) { @@ -6166,8 +6361,6 @@ fun PdfViewerScreen( } ) } - - CustomTopBanner(bannerMessage = bannerMessage) } } } @@ -6314,6 +6507,17 @@ fun PdfViewerScreen( // --- PANEL POPUP --- if (poppedUpPanelBitmap != null) { + val sourcePanelBitmap = poppedUpPanelBitmap + val displayPanelBitmap = remember(sourcePanelBitmap) { + sourcePanelBitmap?.scaledToCanvasLimit() + } + DisposableEffect(sourcePanelBitmap, displayPanelBitmap) { + onDispose { + if (displayPanelBitmap != null && displayPanelBitmap !== sourcePanelBitmap && !displayPanelBitmap.isRecycled) { + displayPanelBitmap.recycle() + } + } + } Box( modifier = Modifier .fillMaxSize() @@ -6327,15 +6531,17 @@ fun PdfViewerScreen( }, contentAlignment = Alignment.Center ) { - Image( - bitmap = poppedUpPanelBitmap!!.asImageBitmap(), - contentDescription = stringResource(R.string.content_desc_annotated_page), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(12.dp)), - contentScale = ContentScale.Fit - ) + displayPanelBitmap?.takeUnless { it.isRecycled }?.let { panelBitmap -> + Image( + bitmap = panelBitmap.asImageBitmap(), + contentDescription = stringResource(R.string.content_desc_annotated_page), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Fit + ) + } IconButton( onClick = { @@ -6832,7 +7038,7 @@ fun PdfViewerScreen( onClick = { showShareDialog = false isShareLoading = true - Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}") + Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}") val filename = getSuggestedFilename( originalFileName, isAnnotated = true ) @@ -6842,10 +7048,10 @@ fun PdfViewerScreen( viewModel.sharePdf( activityContext = context, sourceUri = effectivePdfUri, - annotations = allAnnotations, + annotations = visibleAllAnnotations, richTextPageLayouts = currentRichTextLayouts, - textBoxes = textBoxes.toList(), - highlights = userHighlights.toList(), + textBoxes = visibleTextBoxes, + highlights = visibleUserHighlights, includeAnnotations = true, filename = filename, bookId = currentBookId @@ -6869,7 +7075,7 @@ fun PdfViewerScreen( viewModel.sharePdf( activityContext = context, sourceUri = pdfUri, - annotations = allAnnotations, + annotations = emptyMap(), includeAnnotations = false, filename = filename ) @@ -6917,13 +7123,33 @@ fun PdfViewerScreen( if (showVisualOptionsSheet) { PdfVisualOptionsSheet( systemUiMode = systemUiMode, + showVerticalPageGap = showVerticalPageGap, + showPageNumberOverlay = showPageNumberOverlay, onSystemUiModeChange = { mode -> systemUiMode = mode savePdfSystemUiMode(context, mode) }, + onShowVerticalPageGapChange = { isVisible -> + showVerticalPageGap = isVisible + savePdfVerticalPageGapVisible(context, isVisible) + }, + onShowPageNumberOverlayChange = { isVisible -> + showPageNumberOverlay = isVisible + savePdfPageNumberOverlayVisible(context, isVisible) + }, onDismiss = { showVisualOptionsSheet = false } ) } + if (showScreenOrientationSheet) { + ReaderScreenOrientationSheet( + selectedMode = screenOrientationMode, + onModeSelected = { mode -> + screenOrientationMode = mode + saveReaderScreenOrientationMode(context, mode) + }, + onDismiss = { showScreenOrientationSheet = false } + ) + } if (showCustomizeToolsSheet) { PdfCustomizeToolsSheet( hiddenTools = hiddenTools, diff --git a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt index 520c777..fe49a33 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt @@ -57,7 +57,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG import timber.log.Timber import java.io.File @@ -267,18 +266,18 @@ class TextPaginationEngine { dirtyGlobalIndex: Int = 0 ): List { val totalLen = globalText.length - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " + "margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex" ) if (totalLen == 0) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0") + Timber.d("android.paginate empty -> p0:0-0") return listOf( PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx) ) } if (pageWidthPx <= 0 || pageHeightPx <= 0) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size") + Timber.d("android.paginate aborted invalid page size") return emptyList() } @@ -320,7 +319,7 @@ class TextPaginationEngine { " Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]" } Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog") - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}") + Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}") return resultLayouts } @@ -350,7 +349,7 @@ private fun MutableList.appendMeasuredAndroidRichTextSegment( pageHeightPx = pageHeightPx ) ) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd" ) return nextPageIndex + 1 @@ -398,11 +397,11 @@ private fun MutableList.appendMeasuredAndroidRichTextSegment( ) ) if (isLastContentPage && explicitBreakEnd != null) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd" ) } else if (!fitsOnPage) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex" ) } @@ -475,12 +474,12 @@ class PdfRichTextRepository(private val context: Context) { suspend fun load(bookId: String) { withContext(Dispatchers.IO) { val file = getFile(bookId) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}" ) if (!file.exists()) { _document.value = GlobalRichDocument("", emptyList()) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId") + Timber.d("android.repository.load missing -> empty book=$bookId") return@withContext } try { @@ -508,11 +507,11 @@ class PdfRichTextRepository(private val context: Context) { ) } _document.value = GlobalRichDocument(text, spans) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}" ) } catch (e: Exception) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId") + Timber.e(e, "android.repository.load failed book=$bookId") Timber.e(e, "Failed to load rich text doc") _document.value = GlobalRichDocument("", emptyList()) } @@ -523,7 +522,7 @@ class PdfRichTextRepository(private val context: Context) { _document.value = document withContext(Dispatchers.IO) { try { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}" ) val obj = JSONObject().apply { @@ -548,11 +547,11 @@ class PdfRichTextRepository(private val context: Context) { } val file = getFile(bookId) file.writeText(obj.toString()) - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + Timber.d( "android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}" ) } catch (e: Exception) { - Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId") + Timber.e(e, "android.repository.save failed book=$bookId") Timber.e(e, "Failed to save rich text doc") } } diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt index 8205b6b..5b6b0e4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -14,6 +14,7 @@ import android.net.Uri import android.os.Build import com.aryan.reader.FileType import com.aryan.reader.R +import com.aryan.reader.pptx.PptxDocumentWrapper import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfPageKt @@ -79,7 +80,20 @@ object DocumentFactory { val catalogId = uri.getQueryParameter("catalogId") return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId) } - return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + return if (type == FileType.PPTX) { + val cacheFile = File(context.cacheDir, "temp_pptx_${System.currentTimeMillis()}.pptx") + try { + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } ?: throw Exception("Failed to open PPTX") + } + } catch (e: Exception) { + runCatching { cacheFile.delete() } + throw e + } + PptxDocumentWrapper(cacheFile, deleteOnClose = true) + } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}") withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { input -> diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt index 5795c11..f2d1fc4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt @@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight import org.json.JSONArray import org.json.JSONObject import java.util.Locale +import java.util.UUID data class PdfTextBox( val id: String, @@ -54,7 +55,9 @@ data class PdfAnnotation( val pageIndex: Int, val points: List, val color: Color, - val strokeWidth: Float + val strokeWidth: Float, + val id: String = UUID.randomUUID().toString(), + val note: String? = null ) object AnnotationSerializer { @@ -63,11 +66,15 @@ object AnnotationSerializer { annotations.forEach { (_, list) -> list.forEach { annotation -> val obj = JSONObject() + obj.put("id", annotation.id) obj.put("pageIndex", annotation.pageIndex) obj.put("annotationType", annotation.type.name) obj.put("inkType", annotation.inkType.name) obj.put("color", annotation.color.toArgb()) obj.put("strokeWidth", annotation.strokeWidth.toDouble()) + if (!annotation.note.isNullOrBlank()) { + obj.put("note", annotation.note) + } val pointsArray = JSONArray() annotation.points.forEach { p -> @@ -123,7 +130,10 @@ object AnnotationSerializer { pageIndex = pageIndex, points = points, color = Color(colorInt), - strokeWidth = strokeWidth + strokeWidth = strokeWidth, + id = obj.optString("id").takeIf { it.isNotBlank() } + ?: UUID.randomUUID().toString(), + note = obj.optString("note").takeIf { it.isNotBlank() } ) if (!resultMap.containsKey(pageIndex)) { @@ -278,4 +288,4 @@ object HighlightSerializer { } return result } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt index 9a8665e..73ea063 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationRepository.kt @@ -40,6 +40,8 @@ class PdfAnnotationRepository(private val context: Context) { Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}") if (annotations.isEmpty()) { + val file = getFile(bookId) + if (file.exists()) file.delete() return@withContext } @@ -81,4 +83,4 @@ class PdfAnnotationRepository(private val context: Context) { return if (valid) file else null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt index 6786cad..f26b065 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt @@ -114,6 +114,9 @@ interface PdfTextDao { @Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId") suspend fun getIndexedPageIndices(bookId: String): List + @Query("DELETE FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex") + suspend fun deletePageText(bookId: String, pageIndex: Int) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertPageText(entity: PdfSearchIndex) @@ -158,4 +161,4 @@ abstract class PdfTextDatabase : RoomDatabase() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt index 10d30e7..c81b16c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.Color import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import com.aryan.reader.pdf.ReaderDocument private const val TAG = "PdfSearchDiag" @@ -55,6 +56,11 @@ class PdfTextRepository(context: Context) { private val dao = db.pdfTextDao() private val metaDao = db.pdfMetaDao() + private suspend fun replacePageText(bookId: String, pageIndex: Int, content: String) { + dao.deletePageText(bookId, pageIndex) + dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = content)) + } + suspend fun getPageRatios(bookId: String): List? { return withContext(Dispatchers.IO) { val meta = metaDao.getMetadata(bookId) @@ -250,7 +256,7 @@ class PdfTextRepository(context: Context) { Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean") } else if (text.isNotBlank()) { Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).") - dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text)) + replacePageText(bookId = bookId, pageIndex = pageIndex, content = text) } else { Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.") } @@ -279,6 +285,69 @@ class PdfTextRepository(context: Context) { } } + suspend fun indexReaderPage( + bookId: String, + document: ReaderDocument, + pageIndex: Int, + onOcrModelDownloading: () -> Unit = {} + ): Boolean { + return withContext(Dispatchers.IO) { + var text = "" + var ocrUsed = false + + try { + document.openPage(pageIndex)?.use { page -> + page.openTextPage().use { textPage -> + val count = textPage.textPageCountChars() + if (count > 0) { + text = textPage.textPageGetText(0, count).orEmpty() + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "ReaderDocument extraction failed for page $pageIndex") + } + + if (text.isBlank()) { + var bitmap: android.graphics.Bitmap? = null + try { + document.openPage(pageIndex)?.use { page -> + val targetWidth = 1080 + val pageWidth = page.getPageWidthPoint() + val pageHeight = page.getPageHeightPoint() + if (pageWidth > 0 && pageHeight > 0) { + val aspectRatio = pageWidth.toFloat() / pageHeight.toFloat() + val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1) + bitmap = createBitmap(targetWidth, targetHeight) + page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false) + } + } + + bitmap?.let { + try { + val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading) + text = visionText?.text.orEmpty() + ocrUsed = true + } finally { + it.recycle() + bitmap = null + } + } + } catch (e: Exception) { + bitmap?.recycle() + Timber.tag(TAG).e(e, "ReaderDocument OCR failed for page $pageIndex") + } + } + + val cleaned = cleanIndexedText(text) + if (cleaned.isNotBlank()) { + replacePageText(bookId = bookId, pageIndex = pageIndex, content = cleaned) + } + + ocrUsed && cleaned.isNotEmpty() + } + } + suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean { return withContext(Dispatchers.IO) { try { @@ -599,4 +668,17 @@ class PdfTextRepository(context: Context) { return null } + + private fun cleanIndexedText(raw: String): String { + if (raw.isBlank()) return "" + var text = raw + val patterns = listOf( + Regex("(?i)file:/?/?/?\\S+"), + Regex("(?i)/data/user/\\d+/\\S+"), + Regex("(?i)/storage/emulated/\\d+/\\S+"), + Regex("(?i)\\S*com\\.aryan\\.reader\\S*") + ) + patterns.forEach { pattern -> text = text.replace(pattern, " ") } + return text.replace(Regex("\\s+"), " ").trim() + } } diff --git a/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt b/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt new file mode 100644 index 0000000..93eeb30 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt @@ -0,0 +1,2544 @@ +package com.aryan.reader.pptx + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import android.net.Uri +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.text.style.LeadingMarginSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.TypefaceSpan +import androidx.core.graphics.createBitmap +import com.aryan.reader.pdf.DummyTextPage +import com.aryan.reader.pdf.ReaderDocument +import com.aryan.reader.pdf.ReaderLink +import com.aryan.reader.pdf.ReaderPage +import com.aryan.reader.pdf.ReaderTextPage +import com.aryan.reader.pdf.ReaderTextRect +import io.legere.pdfiumandroid.api.Bookmark +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import timber.log.Timber +import java.io.File +import java.security.MessageDigest +import java.util.Locale +import java.util.zip.ZipFile +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import androidx.core.graphics.withSave +import androidx.core.graphics.withTranslation + +internal const val PPTX_RENDERER_VERSION = 6 +private const val EMU_PER_POINT = 12_700f +private const val DEFAULT_SLIDE_WIDTH_EMU = 12_192_000 +private const val DEFAULT_SLIDE_HEIGHT_EMU = 6_858_000 +private const val DEFAULT_TEXT_SIZE_PT = 18f +private const val DEFAULT_TEXT_MARGIN_PT = 91_440f / EMU_PER_POINT +private const val DEFAULT_LINE_SPACING_MULTIPLE = 1.0f +private const val MAX_NUMBERING_LEVELS = 9 + +internal data class PptxDeck( + val widthPoint: Int, + val heightPoint: Int, + val slides: List +) + +internal data class PptxSlide( + val widthPoint: Int, + val heightPoint: Int, + val backgroundColor: Int?, + val elements: List, + val text: String, + val charBoxes: List +) + +internal data class PptxCharBox( + val char: Char, + val bounds: RectF +) + +internal sealed interface PptxElement { + val bounds: RectF +} + +internal data class PptxShapeElement( + override val bounds: RectF, + val preset: String, + val fillColor: Int?, + val gradientFill: PptxGradientFill? = null, + val lineColor: Int?, + val lineWidthPoint: Float, + val paragraphs: List, + val hyperlink: String?, + val placeholderKey: PptxPlaceholderKey?, + val textInsets: PptxTextInsets = PptxTextInsets(), + val verticalAnchor: PptxVerticalAnchor = PptxVerticalAnchor.TOP, + val rotationDegrees: Float = 0f, + val renderText: Boolean = true, + val fontScale: Float = 1f, + val lineSpacingReduction: Float = 0f, + val autoFitMode: PptxAutoFitMode = PptxAutoFitMode.NONE, + val customGeometry: PptxCustomGeometry? = null +) : PptxElement + +internal data class PptxImageElement( + override val bounds: RectF, + val bytes: ByteArray, + val contentType: String?, + val crop: PptxImageCrop = PptxImageCrop(), + val rotationDegrees: Float = 0f, + val opacity: Float = 1f +) : PptxElement + +internal data class PptxImageCrop( + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f +) + +internal data class PptxTableElement( + override val bounds: RectF, + val rows: List, + val rotationDegrees: Float = 0f +) : PptxElement + +internal data class PptxTableRow( + val heightPoint: Float?, + val cells: List +) + +internal data class PptxTableCell( + val widthPoint: Float?, + val fillColor: Int?, + val lineColor: Int?, + val paragraphs: List, + val textInsets: PptxTextInsets = PptxTextInsets(left = 3.6f, top = 3.6f, right = 3.6f, bottom = 3.6f), + val verticalAnchor: PptxVerticalAnchor = PptxVerticalAnchor.TOP +) + +internal data class PptxParagraph( + val runs: List, + val alignment: PptxTextAlign = PptxTextAlign.START, + val bullet: String? = null, + val level: Int = 0, + val marginLeftPt: Float? = null, + val indentPt: Float? = null, + val spaceBeforePt: Float = 0f, + val spaceAfterPt: Float = 0f, + val lineSpacingMultiple: Float = DEFAULT_LINE_SPACING_MULTIPLE, + val alignmentExplicit: Boolean = false, + val bulletExplicit: Boolean = false, + val spaceBeforeExplicit: Boolean = false, + val spaceAfterExplicit: Boolean = false, + val lineSpacingExplicit: Boolean = false +) + +internal data class PptxTextRun( + val text: String, + val sizePt: Float? = null, + val color: Int? = null, + val bold: Boolean = false, + val italic: Boolean = false, + val typeface: String? = null, + val baseline: Float = 0f, + val sizeExplicit: Boolean = false, + val colorExplicit: Boolean = false, + val boldExplicit: Boolean = false, + val italicExplicit: Boolean = false, + val typefaceExplicit: Boolean = false, + val baselineExplicit: Boolean = false +) + +internal enum class PptxTextAlign { + START, + CENTER, + END +} + +internal enum class PptxVerticalAnchor { + TOP, + MIDDLE, + BOTTOM +} + +internal enum class PptxAutoFitMode { + NONE, + NORMAL, + SHAPE +} + +internal data class PptxTextInsets( + val left: Float = DEFAULT_TEXT_MARGIN_PT, + val top: Float = DEFAULT_TEXT_MARGIN_PT, + val right: Float = DEFAULT_TEXT_MARGIN_PT, + val bottom: Float = DEFAULT_TEXT_MARGIN_PT +) + +internal data class PptxGradientFill( + val startColor: Int, + val endColor: Int, + val angleDegrees: Float = 0f +) + +internal data class PptxCustomGeometry( + val width: Float, + val height: Float, + val commands: List +) { + fun toPath(bounds: RectF): Path { + val scaleX = bounds.width() / width.coerceAtLeast(1f) + val scaleY = bounds.height() / height.coerceAtLeast(1f) + fun x(value: Float) = bounds.left + value * scaleX + fun y(value: Float) = bounds.top + value * scaleY + return Path().apply { + commands.forEach { command -> + when (command) { + is PptxPathCommand.MoveTo -> moveTo(x(command.x), y(command.y)) + is PptxPathCommand.LineTo -> lineTo(x(command.x), y(command.y)) + is PptxPathCommand.QuadTo -> quadTo( + x(command.x1), + y(command.y1), + x(command.x2), + y(command.y2) + ) + is PptxPathCommand.CubicTo -> cubicTo( + x(command.x1), + y(command.y1), + x(command.x2), + y(command.y2), + x(command.x3), + y(command.y3) + ) + PptxPathCommand.Close -> close() + } + } + } + } +} + +internal sealed interface PptxPathCommand { + data class MoveTo(val x: Float, val y: Float) : PptxPathCommand + data class LineTo(val x: Float, val y: Float) : PptxPathCommand + data class QuadTo(val x1: Float, val y1: Float, val x2: Float, val y2: Float) : PptxPathCommand + data class CubicTo( + val x1: Float, + val y1: Float, + val x2: Float, + val y2: Float, + val x3: Float, + val y3: Float + ) : PptxPathCommand + object Close : PptxPathCommand +} + +internal data class PptxPlaceholderKey( + val type: String?, + val index: String? +) + +private data class PptxRelationships( + val byId: Map +) + +private data class PptxRelationship( + val id: String, + val target: String, + val resolvedTarget: String, + val type: String, + val targetMode: String? +) + +private data class PptxTheme( + val colors: Map = emptyMap(), + val majorTypeface: String? = null, + val minorTypeface: String? = null +) { + fun color(name: String): Int? { + return colors[name] ?: colors[name.lowercase(Locale.ROOT)] + } +} + +private data class ParsedPart( + val backgroundColor: Int? = null, + val elements: List = emptyList(), + val textDefaults: PptxTextDefaults = PptxTextDefaults() +) + +private data class PptxTextDefaults( + val title: Map = emptyMap(), + val body: Map = emptyMap(), + val other: Map = emptyMap() +) { + fun merge(override: PptxTextDefaults): PptxTextDefaults { + return PptxTextDefaults( + title = title.mergeStyles(override.title), + body = body.mergeStyles(override.body), + other = other.mergeStyles(override.other) + ) + } + + fun forPlaceholder(key: PptxPlaceholderKey?): Map { + return when (key?.type?.placeholderFamily()) { + "title" -> title + "body", null -> if (key != null) body else other + "subtitle" -> other.ifEmpty { body } + else -> other + } + } +} + +private data class PptxParagraphStyle( + val alignment: PptxTextAlign? = null, + val bullet: String? = null, + val autoNumberType: String? = null, + val autoNumberStartAt: Int? = null, + val bulletExplicit: Boolean = false, + val marginLeftPt: Float? = null, + val indentPt: Float? = null, + val spaceBeforePt: Float? = null, + val spaceAfterPt: Float? = null, + val lineSpacingMultiple: Float? = null, + val run: PptxRunStyle = PptxRunStyle() +) { + fun merge(override: PptxParagraphStyle): PptxParagraphStyle { + return PptxParagraphStyle( + alignment = override.alignment ?: alignment, + bullet = if (override.bulletExplicit) override.bullet else bullet, + autoNumberType = if (override.bulletExplicit) override.autoNumberType else autoNumberType, + autoNumberStartAt = if (override.bulletExplicit) override.autoNumberStartAt else autoNumberStartAt, + bulletExplicit = bulletExplicit || override.bulletExplicit, + marginLeftPt = override.marginLeftPt ?: marginLeftPt, + indentPt = override.indentPt ?: indentPt, + spaceBeforePt = override.spaceBeforePt ?: spaceBeforePt, + spaceAfterPt = override.spaceAfterPt ?: spaceAfterPt, + lineSpacingMultiple = override.lineSpacingMultiple ?: lineSpacingMultiple, + run = run.merge(override.run) + ) + } +} + +private data class PptxRunStyle( + val sizePt: Float? = null, + val color: Int? = null, + val bold: Boolean? = null, + val italic: Boolean? = null, + val typeface: String? = null, + val baseline: Float? = null +) { + fun merge(override: PptxRunStyle): PptxRunStyle { + return PptxRunStyle( + sizePt = override.sizePt ?: sizePt, + color = override.color ?: color, + bold = override.bold ?: bold, + italic = override.italic ?: italic, + typeface = override.typeface ?: typeface, + baseline = override.baseline ?: baseline + ) + } +} + +private data class PptxTableStyle( + val whole: PptxTableCellStyle = PptxTableCellStyle(), + val firstRow: PptxTableCellStyle = PptxTableCellStyle(), + val lastRow: PptxTableCellStyle = PptxTableCellStyle(), + val firstColumn: PptxTableCellStyle = PptxTableCellStyle(), + val lastColumn: PptxTableCellStyle = PptxTableCellStyle(), + val band1Horizontal: PptxTableCellStyle = PptxTableCellStyle(), + val band2Horizontal: PptxTableCellStyle = PptxTableCellStyle() +) { + fun cellStyle( + rowIndex: Int, + columnIndex: Int, + rowCount: Int, + columnCount: Int, + options: PptxTableStyleOptions + ): PptxTableCellStyle { + var style = whole + if (options.bandRow) { + val bandIndex = rowIndex - if (options.firstRow) 1 else 0 + if (bandIndex >= 0) { + style = style.merge(if (bandIndex % 2 == 0) band1Horizontal else band2Horizontal) + } + } + if (options.firstRow && rowIndex == 0) style = style.merge(firstRow) + if (options.lastRow && rowIndex == rowCount - 1) style = style.merge(lastRow) + if (options.firstColumn && columnIndex == 0) style = style.merge(firstColumn) + if (options.lastColumn && columnIndex == columnCount - 1) style = style.merge(lastColumn) + return style + } +} + +private data class PptxTableCellStyle( + val fillColor: Int? = null, + val lineColor: Int? = null, + val run: PptxRunStyle = PptxRunStyle() +) { + fun merge(override: PptxTableCellStyle): PptxTableCellStyle { + return PptxTableCellStyle( + fillColor = override.fillColor ?: fillColor, + lineColor = override.lineColor ?: lineColor, + run = run.merge(override.run) + ) + } +} + +private data class PptxTableStyleOptions( + val firstRow: Boolean = false, + val lastRow: Boolean = false, + val firstColumn: Boolean = false, + val lastColumn: Boolean = false, + val bandRow: Boolean = false +) + +private data class PptxGroupTransform( + val scaleX: Float = 1f, + val scaleY: Float = 1f, + val dx: Float = 0f, + val dy: Float = 0f, + val rotationDegrees: Float = 0f +) { + fun then(child: PptxGroupTransform): PptxGroupTransform { + return PptxGroupTransform( + scaleX = scaleX * child.scaleX, + scaleY = scaleY * child.scaleY, + dx = dx + child.dx * scaleX, + dy = dy + child.dy * scaleY, + rotationDegrees = rotationDegrees + child.rotationDegrees + ) + } + + fun apply(element: PptxElement): PptxElement { + if (this == IDENTITY) return element + return when (element) { + is PptxShapeElement -> element.copy( + bounds = mapRect(element.bounds), + lineWidthPoint = element.lineWidthPoint * averageScale(), + rotationDegrees = element.rotationDegrees + rotationDegrees + ) + is PptxImageElement -> element.copy( + bounds = mapRect(element.bounds), + rotationDegrees = element.rotationDegrees + rotationDegrees + ) + is PptxTableElement -> element.copy( + bounds = mapRect(element.bounds), + rotationDegrees = element.rotationDegrees + rotationDegrees + ) + } + } + + private fun mapRect(rect: RectF): RectF { + val left = rect.left * scaleX + dx + val right = rect.right * scaleX + dx + val top = rect.top * scaleY + dy + val bottom = rect.bottom * scaleY + dy + return RectF(min(left, right), min(top, bottom), max(left, right), max(top, bottom)) + } + + private fun averageScale(): Float = ((scaleX + scaleY) / 2f).coerceAtLeast(0.01f) + + companion object { + val IDENTITY = PptxGroupTransform() + + fun fromGroup(group: Element): PptxGroupTransform { + val xfrm = group.childrenByLocalTag("grpSpPr") + .firstOrNull() + ?.childrenByLocalTag("xfrm") + ?.firstOrNull() + ?: return IDENTITY + val off = xfrm.childrenByLocalTag("off").firstOrNull() + val ext = xfrm.childrenByLocalTag("ext").firstOrNull() + val chOff = xfrm.childrenByLocalTag("chOff").firstOrNull() + val chExt = xfrm.childrenByLocalTag("chExt").firstOrNull() ?: return IDENTITY + val childWidth = chExt.xmlFloat("cx")?.emuToPoint()?.takeIf { it != 0f } ?: return IDENTITY + val childHeight = chExt.xmlFloat("cy")?.emuToPoint()?.takeIf { it != 0f } ?: return IDENTITY + val scaleX = (ext?.xmlFloat("cx")?.emuToPoint() ?: childWidth) / childWidth + val scaleY = (ext?.xmlFloat("cy")?.emuToPoint() ?: childHeight) / childHeight + val childX = chOff?.xmlFloat("x")?.emuToPoint() ?: 0f + val childY = chOff?.xmlFloat("y")?.emuToPoint() ?: 0f + val offX = off?.xmlFloat("x")?.emuToPoint() ?: 0f + val offY = off?.xmlFloat("y")?.emuToPoint() ?: 0f + return PptxGroupTransform( + scaleX = scaleX, + scaleY = scaleY, + dx = offX - childX * scaleX, + dy = offY - childY * scaleY, + rotationDegrees = xfrm.xmlFloat("rot")?.let { it / 60_000f } ?: 0f + ) + } + } +} + +internal object PptxDeckCache { + private const val MAX_ENTRIES = 4 + private val cache = object : LinkedHashMap(MAX_ENTRIES, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_ENTRIES + } + } + + fun load(file: File): PptxDeck { + val key = "${file.contentHash()}:${file.length()}:$PPTX_RENDERER_VERSION" + synchronized(cache) { + cache[key]?.let { return it } + } + val parsed = PptxDocumentParser.parse(file) + synchronized(cache) { + cache[key] = parsed + } + return parsed + } +} + +internal object PptxDocumentParser { + fun parse(file: File): PptxDeck { + ZipFile(file).use { zip -> + val presentation = zip.xml("ppt/presentation.xml") + ?: error("ppt/presentation.xml not found in PPTX archive.") + val presentationRels = zip.relationshipsFor("ppt/presentation.xml") + val width = (presentation.firstByLocalTag("sldSz")?.xmlFloat("cx") ?: DEFAULT_SLIDE_WIDTH_EMU.toFloat()).emuToPointInt() + val height = (presentation.firstByLocalTag("sldSz")?.xmlFloat("cy") ?: DEFAULT_SLIDE_HEIGHT_EMU.toFloat()).emuToPointInt() + val slidePaths = presentation.allByLocalTag("sldId") + .mapNotNull { slideId -> slideId.xmlAttr("r:id") } + .mapNotNull { relId -> presentationRels.byId[relId]?.resolvedTarget } + .ifEmpty { + zip.entries().asSequence() + .map { it.name } + .filter { it.matches(Regex("""ppt/slides/slide\d+\.xml""")) } + .sortedWith(naturalSlidePathComparator()) + .toList() + } + + val slides = slidePaths.mapNotNull { slidePath -> + runCatching { parseSlide(zip, presentation, slidePath, width, height) } + .onFailure { Timber.w(it, "Failed to parse PPTX slide $slidePath") } + .getOrNull() + } + + return PptxDeck( + widthPoint = width, + heightPoint = height, + slides = slides.ifEmpty { + listOf( + PptxSlide( + widthPoint = width, + heightPoint = height, + backgroundColor = Color.WHITE, + elements = emptyList(), + text = "", + charBoxes = emptyList() + ) + ) + } + ) + } + } + + private fun parseSlide( + zip: ZipFile, + presentation: Element, + slidePath: String, + width: Int, + height: Int + ): PptxSlide { + val slideXml = zip.xml(slidePath) ?: error("Missing slide part: $slidePath") + val slideRels = zip.relationshipsFor(slidePath) + val layoutPath = slideRels.byId.values + .firstOrNull { it.type.endsWith("/slideLayout", ignoreCase = true) } + ?.resolvedTarget + val layoutRels = layoutPath?.let { zip.relationshipsFor(it) } + val masterPath = layoutRels?.byId?.values + ?.firstOrNull { it.type.endsWith("/slideMaster", ignoreCase = true) } + ?.resolvedTarget + val masterRels = masterPath?.let { zip.relationshipsFor(it) } + val themePath = masterRels?.byId?.values + ?.firstOrNull { it.type.endsWith("/theme", ignoreCase = true) } + ?.resolvedTarget + val theme = themePath?.let { path -> zip.xml(path)?.let(::parseTheme) } ?: PptxTheme() + val presentationDefaults = presentation.presentationTextDefaults(theme) + + val master = masterPath?.let { path -> + zip.xml(path)?.let { + parsePart( + zip = zip, + document = it, + relationships = zip.relationshipsFor(path), + theme = theme, + renderPlaceholderText = false, + inheritedTextDefaults = presentationDefaults + ) + } + } ?: ParsedPart() + val layout = layoutPath?.let { path -> + zip.xml(path)?.let { + parsePart( + zip = zip, + document = it, + relationships = zip.relationshipsFor(path), + theme = theme, + renderPlaceholderText = false, + inheritedTextDefaults = master.textDefaults + ) + } + } ?: ParsedPart() + val slide = parsePart( + zip = zip, + document = slideXml, + relationships = slideRels, + theme = theme, + renderPlaceholderText = true, + inheritedTextDefaults = layout.textDefaults + ) + val inheritedElements = master.elements + layout.elements + val elements = inheritedElements + inheritPlaceholderProperties(slide.elements, inheritedElements) + val backgroundColor = slide.backgroundColor ?: layout.backgroundColor ?: master.backgroundColor ?: Color.WHITE + val textIndex = PptxTextIndexer.index(elements) + + return PptxSlide( + widthPoint = width, + heightPoint = height, + backgroundColor = backgroundColor, + elements = elements, + text = textIndex.text, + charBoxes = textIndex.charBoxes + ) + } + + private fun parsePart( + zip: ZipFile, + document: Element, + relationships: PptxRelationships, + theme: PptxTheme, + renderPlaceholderText: Boolean, + inheritedTextDefaults: PptxTextDefaults = PptxTextDefaults() + ): ParsedPart { + val partTheme = theme.withColorMap(document.colorMapElement()) + val textDefaults = inheritedTextDefaults.merge(document.textDefaults(partTheme)) + val background = document.firstByLocalTag("bgPr")?.solidFillColor(partTheme) + ?: document.firstByLocalTag("bgRef")?.schemeColor(partTheme) + val tableStyles = zip.tableStyles(partTheme) + val elements = mutableListOf() + val tree = document.firstByLocalTag("spTree") ?: document + tree.children().forEach { child -> + parseDrawingElement( + zip = zip, + element = child, + relationships = relationships, + theme = partTheme, + renderPlaceholderText = renderPlaceholderText, + textDefaults = textDefaults, + tableStyles = tableStyles, + output = elements + ) + } + return ParsedPart(backgroundColor = background, elements = elements, textDefaults = textDefaults) + } + + private fun parseDrawingElement( + zip: ZipFile, + element: Element, + relationships: PptxRelationships, + theme: PptxTheme, + renderPlaceholderText: Boolean, + textDefaults: PptxTextDefaults, + tableStyles: Map, + output: MutableList, + transform: PptxGroupTransform = PptxGroupTransform.IDENTITY + ) { + when (element.localTag()) { + "sp", "cxnsp" -> parseShape(element, relationships, theme, renderPlaceholderText, textDefaults) + ?.let { output += transform.apply(it) } + "pic" -> parseImage(zip, element, relationships) + ?.let { output += transform.apply(it) } + "grpsp" -> element.children().forEach { child -> + val childTransform = transform.then(PptxGroupTransform.fromGroup(element)) + parseDrawingElement( + zip = zip, + element = child, + relationships = relationships, + theme = theme, + renderPlaceholderText = renderPlaceholderText, + textDefaults = textDefaults, + tableStyles = tableStyles, + output = output, + transform = childTransform + ) + } + "graphicframe" -> parseGraphicFrame(element, relationships, theme, tableStyles) + ?.let { output += transform.apply(it) } + } + } + + private fun parseShape( + element: Element, + relationships: PptxRelationships, + theme: PptxTheme, + renderPlaceholderText: Boolean, + textDefaults: PptxTextDefaults + ): PptxShapeElement? { + val spPr = element.childrenByLocalTag("spPr").firstOrNull() + val bounds = spPr?.boundsFromTransform() ?: element.boundsFromTransform() + val txBody = element.firstByLocalTag("txBody") + val bodyPr = txBody?.childrenByLocalTag("bodyPr")?.firstOrNull() + val preset = spPr?.childrenByLocalTag("prstGeom")?.firstOrNull()?.xmlAttr("prst") + ?: if (element.localTag() == "cxnsp") "line" else "rect" + val customGeometry = spPr?.childrenByLocalTag("custGeom")?.firstOrNull()?.customGeometry() + val placeholderKey = element.firstByLocalTag("ph")?.placeholderKey() + val style = element.childrenByLocalTag("style").firstOrNull() + val shapeRunDefaults = PptxRunStyle(color = style?.firstByLocalTag("fontRef")?.solidLikeColor(theme)) + val paragraphs = parseTextBody( + txBody = txBody, + theme = theme, + inheritedStyles = textDefaults.forPlaceholder(placeholderKey), + shapeRunDefaults = shapeRunDefaults + ) + val useBackgroundFill = element.xmlAttr("useBgFill").isTruthyXmlFlag() + val fillColor = when { + useBackgroundFill -> null + spPr?.firstDirectByLocalTag("noFill") != null -> null + else -> spPr?.solidFillColor(theme) ?: style?.firstByLocalTag("fillRef")?.solidLikeColor(theme) + } + val gradientFill = spPr?.gradientFill(theme) + val line = spPr?.childrenByLocalTag("ln")?.firstOrNull() + val lineColor = when { + line?.firstDirectByLocalTag("noFill") != null -> null + else -> line?.solidFillColor(theme) ?: style?.firstByLocalTag("lnRef")?.solidLikeColor(theme) + } + val lineWidth = line?.xmlFloat("w")?.emuToPoint() ?: 0.75f + val hyperlink = element.firstByLocalTag("hlinkClick") + ?.xmlAttr("r:id") + ?.let { relationships.byId[it] } + ?.let { rel -> if (rel.targetMode.equals("External", ignoreCase = true)) rel.target else rel.resolvedTarget } + + if (bounds.width() <= 0f && bounds.height() <= 0f && paragraphs.isEmpty()) return null + return PptxShapeElement( + bounds = bounds, + preset = preset.lowercase(Locale.ROOT), + fillColor = fillColor, + gradientFill = gradientFill, + lineColor = lineColor, + lineWidthPoint = lineWidth, + paragraphs = paragraphs, + hyperlink = hyperlink, + placeholderKey = placeholderKey, + textInsets = bodyPr?.textInsets() ?: PptxTextInsets(), + verticalAnchor = bodyPr?.verticalAnchor() ?: PptxVerticalAnchor.TOP, + rotationDegrees = spPr?.rotationDegreesFromTransform() ?: element.rotationDegreesFromTransform(), + renderText = placeholderKey == null || renderPlaceholderText, + fontScale = bodyPr?.autoFitFontScale() ?: 1f, + lineSpacingReduction = bodyPr?.autoFitLineSpacingReduction() ?: 0f, + autoFitMode = bodyPr?.autoFitMode() ?: PptxAutoFitMode.NONE, + customGeometry = customGeometry + ) + } + + private fun parseImage( + zip: ZipFile, + element: Element, + relationships: PptxRelationships + ): PptxImageElement? { + val blip = element.firstByLocalTag("blip") ?: return null + val relId = blip.xmlAttr("r:embed") ?: blip.xmlAttr("r:link") ?: return null + val rel = relationships.byId[relId] ?: return null + val target = rel.resolvedTarget + val entry = zip.getEntry(target) ?: return null + val bytes = zip.getInputStream(entry).use { it.readBytes() } + val crop = element.firstByLocalTag("srcRect")?.imageCrop() ?: PptxImageCrop() + val bounds = element.childrenByLocalTag("spPr").firstOrNull()?.boundsFromTransform() + ?: element.boundsFromTransform() + return PptxImageElement( + bounds = bounds, + bytes = bytes, + contentType = target.imageContentType(), + crop = crop, + rotationDegrees = element.childrenByLocalTag("spPr").firstOrNull()?.rotationDegreesFromTransform() + ?: element.rotationDegreesFromTransform(), + opacity = blip.imageOpacity() + ) + } + + private fun parseGraphicFrame( + element: Element, + relationships: PptxRelationships, + theme: PptxTheme, + tableStyles: Map + ): PptxElement? { + val table = element.firstByLocalTag("tbl") ?: return parseGraphicPlaceholder(element, relationships, theme) + val bounds = element.boundsFromTransform() + val tblPr = table.childrenByLocalTag("tblPr").firstOrNull() + val tableStyle = tblPr + ?.firstDirectByLocalTag("tableStyleId") + ?.wholeText() + ?.trim() + ?.let { tableStyles[it] } + val styleOptions = PptxTableStyleOptions( + firstRow = tblPr?.xmlAttr("firstRow").isTruthyXmlFlag(), + lastRow = tblPr?.xmlAttr("lastRow").isTruthyXmlFlag(), + firstColumn = tblPr?.xmlAttr("firstCol").isTruthyXmlFlag(), + lastColumn = tblPr?.xmlAttr("lastCol").isTruthyXmlFlag(), + bandRow = tblPr?.xmlAttr("bandRow").isTruthyXmlFlag() + ) + val gridWidths = table.firstByLocalTag("tblGrid") + ?.childrenByLocalTag("gridCol") + ?.map { it.xmlFloat("w")?.emuToPoint() } + .orEmpty() + val rowElements = table.childrenByLocalTag("tr") + val rows = rowElements.mapIndexed { rowIndex, row -> + val cells = row.childrenByLocalTag("tc").mapIndexed { index, cell -> + val tcPr = cell.childrenByLocalTag("tcPr").firstOrNull() + val style = tableStyle?.cellStyle( + rowIndex = rowIndex, + columnIndex = index, + rowCount = rowElements.size, + columnCount = gridWidths.size.coerceAtLeast(row.childrenByLocalTag("tc").size), + options = styleOptions + ) + val textInsets = tcPr?.textInsets() ?: PptxTextInsets(left = 3.6f, top = 3.6f, right = 3.6f, bottom = 3.6f) + PptxTableCell( + widthPoint = gridWidths.getOrNull(index), + fillColor = when { + tcPr?.firstDirectByLocalTag("noFill") != null -> null + else -> tcPr?.solidFillColor(theme) ?: style?.fillColor + }, + lineColor = tcPr?.tableCellLineColor(theme) ?: style?.lineColor, + paragraphs = parseTextBody( + txBody = cell.childrenByLocalTag("txBody").firstOrNull(), + theme = theme, + shapeRunDefaults = style?.run ?: PptxRunStyle() + ), + textInsets = textInsets, + verticalAnchor = tcPr?.verticalAnchor() ?: PptxVerticalAnchor.TOP + ) + } + PptxTableRow( + heightPoint = row.xmlFloat("h")?.emuToPoint(), + cells = cells + ) + } + if (rows.all { row -> row.cells.all { it.paragraphs.isEmpty() } }) return null + return PptxTableElement( + bounds = bounds, + rows = rows, + rotationDegrees = element.rotationDegreesFromTransform() + ) + } + + private fun parseGraphicPlaceholder( + element: Element, + relationships: PptxRelationships, + theme: PptxTheme + ): PptxElement? { + val bounds = element.boundsFromTransform() + if (bounds.width() <= 0f || bounds.height() <= 0f) return null + val chartRel = element.firstByLocalTag("chart")?.xmlAttr("r:id") + val diagramRel = element.firstByLocalTag("relIds")?.xmlAttr("r:dm") + val mediaRel = element.firstByLocalTag("videoFile")?.xmlAttr("r:link") + ?: element.firstByLocalTag("audioFile")?.xmlAttr("r:link") + val label = when { + chartRel != null -> "Chart" + diagramRel != null -> "SmartArt" + mediaRel != null -> "Media" + else -> return null + } + val target = (chartRel ?: diagramRel ?: mediaRel)?.let { relationships.byId[it]?.resolvedTarget } + return PptxShapeElement( + bounds = bounds, + preset = "rect", + fillColor = Color.rgb(245, 246, 248), + gradientFill = null, + lineColor = theme.color("tx1") ?: Color.GRAY, + lineWidthPoint = 0.75f, + paragraphs = listOf( + PptxParagraph( + runs = listOf(PptxTextRun(target?.let { "$label: ${it.substringAfterLast('/')}" } ?: label)), + alignment = PptxTextAlign.CENTER + ) + ), + hyperlink = null, + placeholderKey = null, + textInsets = PptxTextInsets(left = 8f, top = 8f, right = 8f, bottom = 8f), + verticalAnchor = PptxVerticalAnchor.MIDDLE, + rotationDegrees = element.rotationDegreesFromTransform() + ) + } + + private fun parseTextBody( + txBody: Element?, + theme: PptxTheme, + inheritedStyles: Map = emptyMap(), + shapeRunDefaults: PptxRunStyle = PptxRunStyle() + ): List { + if (txBody == null) return emptyList() + val localStyles = txBody.childrenByLocalTag("lstStyle") + .firstOrNull() + ?.paragraphStyles(theme) + .orEmpty() + val styles = inheritedStyles.mergeStyles(localStyles) + val numberCounters = IntArray(MAX_NUMBERING_LEVELS) + val numberCounterStarted = BooleanArray(MAX_NUMBERING_LEVELS) + return txBody.childrenByLocalTag("p").mapNotNull { paragraph -> + val pPr = paragraph.childrenByLocalTag("pPr").firstOrNull() + val endParaRunPr = paragraph.childrenByLocalTag("endParaRPr").firstOrNull() + val level = pPr?.xmlInt("lvl")?.coerceAtLeast(0) ?: 0 + val paragraphStyle = (styles[level] ?: styles[0] ?: PptxParagraphStyle()) + .merge(pPr?.paragraphStyle(theme) ?: PptxParagraphStyle()) + val paragraphRunStyle = shapeRunDefaults + .merge(paragraphStyle.run) + .merge(endParaRunPr?.runStyle(theme) ?: PptxRunStyle()) + val runs = mutableListOf() + paragraph.children().forEach { child -> + when (child.localTag()) { + "r", "fld" -> { + val rPr = child.childrenByLocalTag("rPr").firstOrNull() + val runStyle = paragraphRunStyle.merge(rPr?.runStyle(theme) ?: PptxRunStyle()) + val text = child.firstByLocalTag("t")?.wholeText().orEmpty() + if (text.isNotEmpty()) { + runs += PptxTextRun( + text = text, + sizePt = runStyle.sizePt, + color = runStyle.color, + bold = runStyle.bold ?: false, + italic = runStyle.italic ?: false, + typeface = runStyle.typeface, + baseline = runStyle.baseline ?: 0f, + sizeExplicit = rPr?.xmlAttr("sz") != null, + colorExplicit = rPr?.hasTextColor() == true, + boldExplicit = rPr?.xmlAttr("b") != null, + italicExplicit = rPr?.xmlAttr("i") != null, + typefaceExplicit = rPr?.hasTypeface() == true, + baselineExplicit = rPr?.xmlAttr("baseline") != null + ) + } + } + "br" -> { + val rPr = child.childrenByLocalTag("rPr").firstOrNull() + val runStyle = paragraphRunStyle.merge(rPr?.runStyle(theme) ?: PptxRunStyle()) + runs += PptxTextRun( + "\n", + sizePt = runStyle.sizePt, + color = runStyle.color, + bold = runStyle.bold ?: false, + italic = runStyle.italic ?: false, + typeface = runStyle.typeface, + baseline = runStyle.baseline ?: 0f + ) + } + "tab" -> runs += PptxTextRun( + "\t", + sizePt = paragraphRunStyle.sizePt, + color = paragraphRunStyle.color, + bold = paragraphRunStyle.bold ?: false, + italic = paragraphRunStyle.italic ?: false, + typeface = paragraphRunStyle.typeface, + baseline = paragraphRunStyle.baseline ?: 0f + ) + } + } + val safeRuns = runs.ifEmpty { listOf(PptxTextRun("")) } + if (safeRuns.none { it.text.isNotBlank() }) return@mapNotNull null + val bullet = paragraphStyle.resolvedBullet(level, numberCounters, numberCounterStarted) + PptxParagraph( + runs = safeRuns, + alignment = paragraphStyle.alignment ?: PptxTextAlign.START, + bullet = bullet, + level = level, + marginLeftPt = paragraphStyle.marginLeftPt, + indentPt = paragraphStyle.indentPt, + spaceBeforePt = paragraphStyle.spaceBeforePt ?: 0f, + spaceAfterPt = paragraphStyle.spaceAfterPt ?: 0f, + lineSpacingMultiple = paragraphStyle.lineSpacingMultiple ?: DEFAULT_LINE_SPACING_MULTIPLE, + alignmentExplicit = pPr?.xmlAttr("algn") != null, + bulletExplicit = pPr?.hasBulletDefinition() == true, + spaceBeforeExplicit = pPr?.firstByLocalTag("spcBef") != null, + spaceAfterExplicit = pPr?.firstByLocalTag("spcAft") != null, + lineSpacingExplicit = pPr?.firstByLocalTag("lnSpc") != null + ) + } + } + + private fun parseTheme(document: Element): PptxTheme { + val scheme = document.firstByLocalTag("clrScheme") ?: return PptxTheme() + val colors = scheme.children().mapNotNull { colorNode -> + val value = colorNode.firstByLocalTag("srgbClr")?.xmlAttr("val")?.toColorOrNull() + ?: colorNode.firstByLocalTag("sysClr")?.xmlAttr("lastClr")?.toColorOrNull() + value?.let { colorNode.localTag() to it } + }.toMap() + val fontScheme = document.firstByLocalTag("fontScheme") + val majorTypeface = fontScheme?.firstByLocalTag("majorFont") + ?.firstByLocalTag("latin") + ?.xmlAttr("typeface") + ?.takeIf { it.isNotBlank() } + val minorTypeface = fontScheme?.firstByLocalTag("minorFont") + ?.firstByLocalTag("latin") + ?.xmlAttr("typeface") + ?.takeIf { it.isNotBlank() } + val aliases = buildMap { + putAll(colors) + colors["lt1"]?.let { put("bg1", it) } + colors["dk1"]?.let { put("tx1", it) } + colors["lt2"]?.let { put("bg2", it) } + colors["dk2"]?.let { put("tx2", it) } + } + return PptxTheme( + colors = aliases, + majorTypeface = majorTypeface, + minorTypeface = minorTypeface + ) + } + + private fun Element.presentationTextDefaults(theme: PptxTheme): PptxTextDefaults { + val defaults = firstByLocalTag("defaultTextStyle") + ?.paragraphStyles(theme) + .orEmpty() + if (defaults.isEmpty()) return PptxTextDefaults() + return PptxTextDefaults(title = defaults, body = defaults, other = defaults) + } + + private fun ZipFile.xml(path: String): Element? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).use { input -> + Jsoup.parse(input, null, "", Parser.xmlParser()) + } + } + + private fun ZipFile.relationshipsFor(partPath: String): PptxRelationships { + val relsPath = partPath.relationshipsPath() + val document = xml(relsPath) ?: return PptxRelationships(emptyMap()) + val rels = document.allByLocalTag("Relationship").mapNotNull { rel -> + val id = rel.xmlAttr("Id") ?: return@mapNotNull null + val target = rel.xmlAttr("Target") ?: return@mapNotNull null + val type = rel.xmlAttr("Type").orEmpty() + PptxRelationship( + id = id, + target = target, + resolvedTarget = resolveRelationshipTarget(partPath, target, rel.xmlAttr("TargetMode")), + type = type, + targetMode = rel.xmlAttr("TargetMode") + ) + }.associateBy { it.id } + return PptxRelationships(rels) + } + + private fun ZipFile.tableStyles(theme: PptxTheme): Map { + val document = xml("ppt/tableStyles.xml") ?: return emptyMap() + return document.allByLocalTag("tblStyle").mapNotNull { style -> + val id = style.xmlAttr("styleId") ?: return@mapNotNull null + id to PptxTableStyle( + whole = style.childrenByLocalTag("wholeTbl").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + firstRow = style.childrenByLocalTag("firstRow").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + lastRow = style.childrenByLocalTag("lastRow").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + firstColumn = style.childrenByLocalTag("firstCol").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + lastColumn = style.childrenByLocalTag("lastCol").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + band1Horizontal = style.childrenByLocalTag("band1H").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), + band2Horizontal = style.childrenByLocalTag("band2H").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle() + ) + }.toMap() + } +} + +private fun PptxTheme.withColorMap(mapping: Element?): PptxTheme { + if (mapping == null) return this + val mappedColors = colors.toMutableMap() + listOf("bg1", "tx1", "bg2", "tx2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink") + .forEach { alias -> + val target = mapping.xmlAttr(alias) ?: return@forEach + color(target)?.let { mappedColors[alias.lowercase(Locale.ROOT)] = it } + } + return copy(colors = mappedColors) +} + +private fun Element.colorMapElement(): Element? { + firstByLocalTag("overrideClrMapping")?.let { return it } + if (firstByLocalTag("masterClrMapping") != null) return null + return firstByLocalTag("clrMap") +} + +private fun inheritPlaceholderProperties( + slideElements: List, + inheritedElements: List +): List { + val inheritedPlaceholders = inheritedElements + .filterIsInstance() + .filter { it.placeholderKey != null && it.bounds.width() > 0f && it.bounds.height() > 0f } + + if (inheritedPlaceholders.isEmpty()) return slideElements + + return slideElements.map { element -> + val shape = element as? PptxShapeElement ?: return@map element + val key = shape.placeholderKey ?: return@map shape + + val inherited = inheritedPlaceholders.lastOrNull { inherited -> + inherited.placeholderKey?.matches(key) == true + } ?: return@map shape + val shouldInheritBounds = shape.bounds.width() <= 0f || shape.bounds.height() <= 0f + + shape.copy( + bounds = if (shouldInheritBounds) RectF(inherited.bounds) else shape.bounds, + preset = if (shouldInheritBounds && shape.preset == "rect") inherited.preset else shape.preset, + fillColor = shape.fillColor ?: inherited.fillColor, + gradientFill = shape.gradientFill ?: inherited.gradientFill, + lineColor = shape.lineColor ?: inherited.lineColor, + lineWidthPoint = if (shape.lineWidthPoint == 0.75f) inherited.lineWidthPoint else shape.lineWidthPoint, + paragraphs = shape.paragraphs.inheritTextStyles(inherited.paragraphs), + textInsets = if (shape.textInsets == PptxTextInsets()) inherited.textInsets else shape.textInsets, + verticalAnchor = if (shape.verticalAnchor == PptxVerticalAnchor.TOP) inherited.verticalAnchor else shape.verticalAnchor, + rotationDegrees = if (shape.rotationDegrees == 0f) inherited.rotationDegrees else shape.rotationDegrees, + fontScale = if (shape.fontScale == 1f) inherited.fontScale else shape.fontScale, + lineSpacingReduction = if (shape.lineSpacingReduction == 0f) { + inherited.lineSpacingReduction + } else { + shape.lineSpacingReduction + }, + autoFitMode = if (shape.autoFitMode == PptxAutoFitMode.NONE) inherited.autoFitMode else shape.autoFitMode, + customGeometry = shape.customGeometry ?: inherited.customGeometry + ) + } +} + +private fun List.inheritTextStyles(fallback: List): List { + if (isEmpty() || fallback.isEmpty()) return this + return mapIndexed { index, paragraph -> + val fallbackParagraph = fallback.firstOrNull { it.level == paragraph.level } + ?: fallback.getOrNull(index) + ?: fallback.first() + paragraph.inheritTextStyle(fallbackParagraph) + } +} + +private fun PptxParagraph.inheritTextStyle(fallback: PptxParagraph): PptxParagraph { + val fallbackRun = fallback.runs.firstOrNull() + return copy( + runs = runs.map { run -> run.inheritTextStyle(fallbackRun) }, + alignment = if (alignmentExplicit) alignment else fallback.alignment, + bullet = if (bulletExplicit) bullet else fallback.bullet, + marginLeftPt = marginLeftPt ?: fallback.marginLeftPt, + indentPt = indentPt ?: fallback.indentPt, + spaceBeforePt = if (spaceBeforeExplicit) spaceBeforePt else fallback.spaceBeforePt, + spaceAfterPt = if (spaceAfterExplicit) spaceAfterPt else fallback.spaceAfterPt, + lineSpacingMultiple = if (lineSpacingExplicit) lineSpacingMultiple else fallback.lineSpacingMultiple + ) +} + +private fun PptxTextRun.inheritTextStyle(fallback: PptxTextRun?): PptxTextRun { + if (fallback == null) return this + return copy( + sizePt = if (sizeExplicit) sizePt else sizePt ?: fallback.sizePt, + color = if (colorExplicit) color else color ?: fallback.color, + bold = if (boldExplicit) bold else fallback.bold || bold, + italic = if (italicExplicit) italic else fallback.italic || italic, + typeface = if (typefaceExplicit) typeface else typeface ?: fallback.typeface, + baseline = if (baselineExplicit) baseline else baseline.takeUnless { it == 0f } ?: fallback.baseline + ) +} + +internal class PptxDocumentWrapper( + private val file: File, + private val deleteOnClose: Boolean = false +) : ReaderDocument { + private val deck: PptxDeck by lazy { PptxDeckCache.load(file) } + + override suspend fun getPageCount(): Int = deck.slides.size + + override suspend fun openPage(pageIndex: Int): ReaderPage? { + return deck.slides.getOrNull(pageIndex)?.let(::PptxPageWrapper) + } + + override suspend fun getTableOfContents(): List = emptyList() + + override fun close() { + if (deleteOnClose) { + runCatching { file.delete() } + } + } +} + +internal class PptxPageWrapper( + private val slide: PptxSlide +) : ReaderPage { + override suspend fun getPageWidthPoint(): Int = slide.widthPoint + override suspend fun getPageHeightPoint(): Int = slide.heightPoint + override suspend fun getPageRotation(): Int = 0 + + override suspend fun renderPageBitmap( + bitmap: Bitmap, + startX: Int, + startY: Int, + drawSizeX: Int, + drawSizeY: Int, + renderAnnot: Boolean + ) { + withContext(Dispatchers.Default) { + PptxSlideRenderer.render(slide, bitmap, startX, startY, drawSizeX, drawSizeY) + } + } + + override suspend fun mapRectToDevice( + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + coords: RectF + ): Rect { + val scaleX = sizeX.toFloat() / slide.widthPoint.toFloat().coerceAtLeast(1f) + val scaleY = sizeY.toFloat() / slide.heightPoint.toFloat().coerceAtLeast(1f) + return Rect( + (startX + coords.left * scaleX).roundToInt(), + (startY + coords.top * scaleY).roundToInt(), + (startX + coords.right * scaleX).roundToInt(), + (startY + coords.bottom * scaleY).roundToInt() + ) + } + + override suspend fun mapDeviceCoordsToPage( + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + deviceX: Int, + deviceY: Int + ): PointF { + val scaleX = sizeX.toFloat() / slide.widthPoint.toFloat().coerceAtLeast(1f) + val scaleY = sizeY.toFloat() / slide.heightPoint.toFloat().coerceAtLeast(1f) + return PointF( + (deviceX - startX) / scaleX, + (deviceY - startY) / scaleY + ) + } + + override suspend fun openTextPage(): ReaderTextPage { + return if (slide.text.isBlank()) DummyTextPage() else PptxTextPage(slide) + } + + override suspend fun getLinks(): List { + return slide.elements.mapNotNull { element -> + val shape = element as? PptxShapeElement ?: return@mapNotNull null + val link = shape.hyperlink?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + ReaderLink(uri = link, destPageIdx = null, bounds = RectF(shape.bounds)) + } + } + + override fun getNativePointer(): Long = 0L + override fun close() = Unit +} + +internal class PptxTextPage( + private val slide: PptxSlide +) : ReaderTextPage { + override suspend fun textPageCountChars(): Int = slide.text.length + + override suspend fun textPageGetText(startIndex: Int, count: Int): String? { + if (count <= 0 || startIndex !in 0..slide.text.length) return "" + val end = (startIndex + count).coerceAtMost(slide.text.length) + return slide.text.substring(startIndex, end) + } + + override suspend fun textPageGetRectsForRanges(ranges: IntArray): List? { + if (ranges.size < 2) return emptyList() + val rects = mutableListOf() + var index = 0 + while (index + 1 < ranges.size) { + val start = ranges[index].coerceIn(0, slide.charBoxes.size) + val length = ranges[index + 1].coerceAtLeast(0) + val end = (start + length).coerceAtMost(slide.charBoxes.size) + val lineRects = slide.charBoxes.subList(start, end) + .filter { !it.char.isWhitespace() } + .groupBy { it.bounds.top.roundToInt() } + .values + .mapNotNull { boxes -> + boxes.fold(null) { acc, box -> + acc?.apply { union(box.bounds) } ?: RectF(box.bounds) + } + } + rects += lineRects.map(::ReaderTextRect) + index += 2 + } + return rects + } + + override suspend fun textPageGetCharIndexAtPos( + x: Double, + y: Double, + xTolerance: Double, + yTolerance: Double + ): Int { + val pointX = x.toFloat() + val pointY = y.toFloat() + val expanded = RectF() + slide.charBoxes.forEachIndexed { index, box -> + expanded.set(box.bounds) + expanded.inset(-xTolerance.toFloat(), -yTolerance.toFloat()) + if (expanded.contains(pointX, pointY)) return index + } + return -1 + } + + override suspend fun textPageGetCharBox(index: Int): RectF? { + return slide.charBoxes.getOrNull(index)?.bounds?.let(::RectF) + } + + override suspend fun textPageGetUnicode(index: Int): Int { + return slide.text.getOrNull(index)?.code ?: 0 + } + + override suspend fun loadWebLink() = null + override fun close() = Unit +} + +internal class PptxCoverGenerator(context: Context) { + private val appContext = context.applicationContext + + suspend fun generateCover(uri: Uri, targetHeight: Int = 800): Bitmap? = withContext(Dispatchers.IO) { + val cacheFile = File(appContext.cacheDir, "pptx_cover_${System.currentTimeMillis()}.pptx") + try { + appContext.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } ?: return@withContext null + + PptxDocumentWrapper(cacheFile, deleteOnClose = true).use { doc -> + val page = doc.openPage(0) ?: return@withContext null + page.use { + val width = it.getPageWidthPoint() + val height = it.getPageHeightPoint() + if (width <= 0 || height <= 0) return@withContext null + val targetWidth = (targetHeight * (width.toFloat() / height.toFloat())).roundToInt().coerceAtLeast(1) + val bitmap = createBitmap(targetWidth, targetHeight) + it.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) + bitmap + } + } + } catch (e: Exception) { + Timber.w(e, "Failed to generate PPTX cover") + null + } finally { + runCatching { cacheFile.delete() } + } + } +} + +private data class PptxTextIndex( + val text: String, + val charBoxes: List +) + +private object PptxTextIndexer { + fun index(elements: List): PptxTextIndex { + val text = StringBuilder() + val charBoxes = mutableListOf() + elements.forEach { element -> + when (element) { + is PptxShapeElement -> appendShapeText(element, text, charBoxes) + is PptxTableElement -> layoutTableCells(element).forEach { cell -> + appendShapeText(cell.asShape(), text, charBoxes) + } + is PptxImageElement -> Unit + } + } + val indexedText = text.toString().trimEnd() + return PptxTextIndex(indexedText, charBoxes.take(indexedText.length)) + } + + private fun appendShapeText( + shape: PptxShapeElement, + text: StringBuilder, + charBoxes: MutableList + ) { + if (!shape.renderText) return + val textBounds = shape.textBounds() + if (textBounds.width() <= 0f || textBounds.height() <= 0f) return + val paragraphs = shape.paragraphs + .mapNotNull { paragraph -> paragraph.displayText().takeIf { it.isNotBlank() }?.let { paragraph to it } } + if (paragraphs.isEmpty()) return + + val paragraphHeights = paragraphs.map { (paragraph, displayText) -> + paragraph.spaceBeforePt + displayText.lineCount() * paragraph.approximateLineHeight(shape) + paragraph.spaceAfterPt + } + val totalHeight = paragraphHeights.sumOf { it.toDouble() }.toFloat() + val effectiveBounds = if (shape.autoFitMode == PptxAutoFitMode.SHAPE && totalHeight > textBounds.height()) { + RectF(textBounds).apply { bottom = top + totalHeight } + } else { + textBounds + } + var y = when (shape.verticalAnchor) { + PptxVerticalAnchor.TOP -> effectiveBounds.top + PptxVerticalAnchor.MIDDLE -> effectiveBounds.top + ((effectiveBounds.height() - totalHeight) / 2f).coerceAtLeast(0f) + PptxVerticalAnchor.BOTTOM -> effectiveBounds.bottom - totalHeight.coerceAtMost(effectiveBounds.height()) + } + + paragraphs.forEachIndexed { index, (paragraph, displayText) -> + y += paragraph.spaceBeforePt + displayText.lines().forEach { line -> + if (shape.autoFitMode != PptxAutoFitMode.SHAPE && y > effectiveBounds.bottom) return@forEach + appendLine(line, paragraph, shape, effectiveBounds, y, text, charBoxes) + y += paragraph.approximateLineHeight(shape) + } + y += paragraph.spaceAfterPt + if (index < paragraphs.lastIndex && text.lastOrNull() != '\n') { + text.append('\n') + charBoxes += PptxCharBox('\n', RectF(shape.bounds.left, shape.bounds.bottom, shape.bounds.left, shape.bounds.bottom)) + } + } + } + + private fun appendLine( + line: String, + paragraph: PptxParagraph, + shape: PptxShapeElement, + textBounds: RectF, + y: Float, + text: StringBuilder, + charBoxes: MutableList + ) { + if (line.isEmpty()) { + text.append('\n') + charBoxes += PptxCharBox('\n', RectF(textBounds.left, y, textBounds.left, y)) + return + } + val fontSize = scaledTextSize(paragraph.runs.firstOrNull()?.sizePt, shape.fontScale).toFloat() + val estimatedWidth = line.sumOf { char -> + when { + char.isWhitespace() -> 0.33 + char in "ilI.,;:!|" -> 0.3 + char in "MW@#%&" -> 0.85 + else -> 0.55 + } + }.toFloat() * fontSize + val maxLineWidth = textBounds.width().coerceAtLeast(0.5f) + val minLineWidth = min(line.length * 0.5f, maxLineWidth) + val lineWidth = estimatedWidth.coerceIn(minLineWidth, maxLineWidth) + val charAdvance = (lineWidth / line.length.coerceAtLeast(1)).coerceAtLeast(0.5f) + val startX = when (paragraph.alignment) { + PptxTextAlign.START -> textBounds.left + PptxTextAlign.CENTER -> textBounds.left + ((textBounds.width() - lineWidth) / 2f).coerceAtLeast(0f) + PptxTextAlign.END -> textBounds.right - lineWidth + } + val bottom = y + paragraph.approximateLineHeight(shape) + + text.append(line) + line.forEachIndexed { index, char -> + val left = startX + index * charAdvance + val right = if (index == line.lastIndex) startX + lineWidth else left + charAdvance + charBoxes += PptxCharBox( + char = char, + bounds = RectF(left, y, right, bottom).rotatedBounds(shape.bounds, shape.rotationDegrees) + ) + } + } + + private fun String.lineCount(): Int = lines().size.coerceAtLeast(1) + + private fun PptxParagraph.approximateLineHeight(shape: PptxShapeElement): Float { + val fontSize = scaledTextSize(runs.firstOrNull()?.sizePt, shape.fontScale).toFloat() + return (fontSize * 1.2f * effectiveLineSpacing(shape)).coerceAtLeast(1f) + } +} + +private data class LaidOutParagraph( + val text: String, + val layout: StaticLayout, + val x: Float, + val y: Float, + val charBoxes: List +) + +private data class PreparedParagraphLayout( + val paragraph: PptxParagraph, + val text: String, + val layout: StaticLayout +) + +private data class LaidOutTableCell( + val rect: RectF, + val cell: PptxTableCell +) + +private fun LaidOutTableCell.asShape(): PptxShapeElement { + return PptxShapeElement( + bounds = rect, + preset = "rect", + fillColor = cell.fillColor, + gradientFill = null, + lineColor = cell.lineColor, + lineWidthPoint = 0.75f, + paragraphs = cell.paragraphs, + hyperlink = null, + placeholderKey = null, + textInsets = cell.textInsets, + verticalAnchor = cell.verticalAnchor + ) +} + +private val TextLayoutPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textSize = DEFAULT_TEXT_SIZE_PT +} + +private object PptxSlideRenderer { + fun render(slide: PptxSlide, bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int) { + val canvas = Canvas(bitmap) + canvas.drawColor(slide.backgroundColor ?: Color.WHITE) + if (slide.widthPoint <= 0 || slide.heightPoint <= 0) return + canvas.withTranslation(startX.toFloat(), startY.toFloat()) { + scale(drawSizeX.toFloat() / slide.widthPoint, drawSizeY.toFloat() / slide.heightPoint) + + slide.elements.forEach { element -> + when (element) { + is PptxShapeElement -> drawShape(this, element) + is PptxImageElement -> drawImage(this, element) + is PptxTableElement -> drawTable(this, element) + } + } + + } + } + + private fun drawShape(canvas: Canvas, shape: PptxShapeElement) { + canvas.withRotation(shape.bounds, shape.rotationDegrees) { + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + shape.gradientFill?.let { shader = it.toShader(shape.bounds) } + if (shape.gradientFill == null) { + color = shape.fillColor ?: Color.TRANSPARENT + } + } + if (shape.gradientFill != null || (shape.fillColor != null && Color.alpha(shape.fillColor) > 0)) { + drawPresetShape(canvas, shape.bounds, shape.preset, shape.customGeometry, fillPaint) + } + + val strokeColor = shape.lineColor + if (strokeColor != null && Color.alpha(strokeColor) > 0) { + val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = strokeColor + strokeWidth = shape.lineWidthPoint.coerceAtLeast(0.25f) + } + drawPresetShape(canvas, shape.bounds, shape.preset, shape.customGeometry, strokePaint) + } + + drawText(canvas, shape) + } + } + + private fun drawText(canvas: Canvas, shape: PptxShapeElement) { + if (!shape.renderText) return + val layout = layoutParagraphs(shape, shape.bounds) + val clip = shape.textBounds().takeUnless { shape.autoFitMode == PptxAutoFitMode.SHAPE } + layout.forEach { paragraph -> + canvas.withSave { + clip?.let { clipRect(it) } + translate(paragraph.x, paragraph.y) + paragraph.layout.draw(this) + } + } + } + + private fun drawTable(canvas: Canvas, table: PptxTableElement) { + canvas.withRotation(table.bounds, table.rotationDegrees) { + layoutTableCells(table).forEach { laidOutCell -> + val rect = laidOutCell.rect + val cell = laidOutCell.cell + val fill = cell.fillColor + if (fill != null && Color.alpha(fill) > 0) { + canvas.drawRect(rect, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = fill + }) + } + cell.lineColor?.let { lineColor -> + canvas.drawRect(rect, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = lineColor + strokeWidth = 0.5f + }) + } + drawText(canvas, laidOutCell.asShape()) + } + } + } + + private fun drawImage(canvas: Canvas, image: PptxImageElement) { + canvas.withRotation(image.bounds, image.rotationDegrees) { + val bitmap = BitmapFactory.decodeByteArray(image.bytes, 0, image.bytes.size) + if (bitmap != null) { + canvas.drawBitmap( + bitmap, + image.crop.sourceRect(bitmap.width, bitmap.height), + image.bounds, + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + alpha = (255f * image.opacity.coerceIn(0f, 1f)).roundToInt().coerceIn(0, 255) + } + ) + bitmap.recycle() + } else { + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.LTGRAY + } + canvas.drawRect(image.bounds, paint) + paint.style = Paint.Style.STROKE + paint.color = Color.GRAY + paint.strokeWidth = 0.75f + canvas.drawRect(image.bounds, paint) + } + } + } +} + +private fun layoutParagraphs(shape: PptxShapeElement, bounds: RectF): List { + if (shape.paragraphs.isEmpty() || bounds.width() <= 0f || bounds.height() <= 0f) return emptyList() + val baseTextBounds = shape.textBounds(bounds) + if (baseTextBounds.width() <= 0f || baseTextBounds.height() <= 0f) return emptyList() + + var fontScale = shape.fontScale + var prepared = prepareParagraphLayouts(shape, baseTextBounds, fontScale) + if (shape.autoFitMode == PptxAutoFitMode.NORMAL && prepared.isNotEmpty()) { + repeat(8) { + val totalHeight = prepared.totalHeight() + if (totalHeight <= baseTextBounds.height() || fontScale <= 0.35f) return@repeat + val fitRatio = (baseTextBounds.height() / totalHeight).coerceIn(0.35f, 0.96f) + fontScale = (fontScale * fitRatio).coerceAtLeast(0.35f) + prepared = prepareParagraphLayouts(shape, baseTextBounds, fontScale) + } + } + if (prepared.isEmpty()) return emptyList() + + val totalHeight = prepared.totalHeight() + val textBounds = if (shape.autoFitMode == PptxAutoFitMode.SHAPE && totalHeight > baseTextBounds.height()) { + RectF(baseTextBounds).apply { bottom = top + totalHeight } + } else { + baseTextBounds + } + var y = when (shape.verticalAnchor) { + PptxVerticalAnchor.TOP -> textBounds.top + PptxVerticalAnchor.MIDDLE -> textBounds.top + ((textBounds.height() - totalHeight) / 2f).coerceAtLeast(0f) + PptxVerticalAnchor.BOTTOM -> textBounds.bottom - totalHeight.coerceAtMost(textBounds.height()) + } + + return prepared.mapNotNull { item -> + y += item.paragraph.spaceBeforePt + if (shape.autoFitMode != PptxAutoFitMode.SHAPE && y > textBounds.bottom) return@mapNotNull null + val paragraphY = y + val charBoxes = item.layout.charBoxesFor(item.text, textBounds.left, paragraphY) + .map { rect -> rect.rotatedBounds(shape.bounds, shape.rotationDegrees) } + y += item.layout.height + item.paragraph.spaceAfterPt + LaidOutParagraph( + text = item.text, + layout = item.layout, + x = textBounds.left, + y = paragraphY, + charBoxes = charBoxes + ) + } +} + +private fun prepareParagraphLayouts( + shape: PptxShapeElement, + textBounds: RectF, + fontScale: Float +): List { + val layoutWidth = textBounds.width().roundToInt().coerceAtLeast(1) + return shape.paragraphs.mapNotNull { paragraph -> + val text = paragraph.displayText() + if (text.isBlank()) return@mapNotNull null + val spannable = paragraph.toSpannable(text, fontScale) + val layout = StaticLayout.Builder + .obtain(spannable, 0, spannable.length, TextLayoutPaint, layoutWidth) + .setAlignment(paragraph.alignment.toLayoutAlignment()) + .setIncludePad(true) + .setLineSpacing(0f, paragraph.effectiveLineSpacing(shape)) + .build() + PreparedParagraphLayout(paragraph, text, layout) + } +} + +private fun List.totalHeight(): Float { + return sumOf { item -> + (item.paragraph.spaceBeforePt + item.layout.height + item.paragraph.spaceAfterPt).toDouble() + }.toFloat() +} + +private fun PptxParagraph.effectiveLineSpacing(shape: PptxShapeElement): Float { + val reduced = lineSpacingMultiple * (1f - shape.lineSpacingReduction) + return reduced.coerceIn(0.9f, 2.5f) +} + +private fun PptxShapeElement.textBounds(sourceBounds: RectF = bounds): RectF { + return RectF( + sourceBounds.left + textInsets.left, + sourceBounds.top + textInsets.top, + sourceBounds.right - textInsets.right, + sourceBounds.bottom - textInsets.bottom + ) +} + +private fun PptxParagraph.displayText(): String { + val text = runs.joinToString("") { it.text } + val prefix = bullet?.takeIf { it.isNotBlank() }?.let { "$it " }.orEmpty() + return prefix + text +} + +private fun PptxParagraph.toSpannable(displayText: String, fontScale: Float): SpannableStringBuilder { + val builder = SpannableStringBuilder(displayText) + val bulletPrefixLength = bullet?.takeIf { it.isNotBlank() }?.let { it.length + 1 } ?: 0 + val baseMargin = marginLeftPt ?: ((level * 18f) + if (bulletPrefixLength > 0) 18f else 0f) + val firstMargin = (baseMargin + (indentPt ?: if (bulletPrefixLength > 0) -12f else 0f)) + .roundToInt() + .coerceAtLeast(0) + val restMargin = baseMargin.roundToInt().coerceAtLeast(0) + if (firstMargin > 0 || restMargin > 0) { + builder.setSpan( + LeadingMarginSpan.Standard(firstMargin, restMargin), + 0, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + var offset = bulletPrefixLength + runs.forEach { run -> + val start = offset + val end = (start + run.text.length).coerceAtMost(builder.length) + if (start >= end) return@forEach + builder.setSpan( + AbsoluteSizeSpan(scaledTextSize(run.sizePt, fontScale), false), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + run.color?.let { color -> + builder.setSpan(ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (run.bold || run.italic) { + builder.setSpan( + StyleSpan( + when { + run.bold && run.italic -> Typeface.BOLD_ITALIC + run.bold -> Typeface.BOLD + else -> Typeface.ITALIC + } + ), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + run.typeface?.takeIf { it.isNotBlank() && !it.startsWith("+") }?.let { family -> + builder.setSpan(TypefaceSpan(family), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + when { + run.baseline > 0.05f -> { + builder.setSpan(SuperscriptSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + builder.setSpan(RelativeSizeSpan(0.75f), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + run.baseline < -0.05f -> { + builder.setSpan(SubscriptSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + builder.setSpan(RelativeSizeSpan(0.75f), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + offset = end + } + + if (bulletPrefixLength > 0) { + val firstRun = runs.firstOrNull() + builder.setSpan( + AbsoluteSizeSpan(scaledTextSize(firstRun?.sizePt, fontScale), false), + 0, + bulletPrefixLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + firstRun?.color?.let { color -> + builder.setSpan(ForegroundColorSpan(color), 0, bulletPrefixLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + return builder +} + +private fun PptxTextAlign.toLayoutAlignment(): Layout.Alignment { + return when (this) { + PptxTextAlign.START -> Layout.Alignment.ALIGN_NORMAL + PptxTextAlign.CENTER -> Layout.Alignment.ALIGN_CENTER + PptxTextAlign.END -> Layout.Alignment.ALIGN_OPPOSITE + } +} + +private fun StaticLayout.charBoxesFor(text: String, originX: Float, originY: Float): List { + if (text.isEmpty()) return emptyList() + return text.indices.map { index -> + val line = getLineForOffset(index.coerceIn(0, text.length)) + val nextOffset = (index + 1).coerceAtMost(text.length) + val left = runCatching { getPrimaryHorizontal(index) }.getOrDefault(getLineLeft(line)) + val right = runCatching { getPrimaryHorizontal(nextOffset) }.getOrDefault(left) + val minX = min(left, right) + val maxX = max(left, right).let { if (it == minX) it + 0.5f else it } + RectF( + originX + minX, + originY + getLineTop(line), + originX + maxX, + originY + getLineBottom(line) + ) + } +} + +private fun RectF.rotatedBounds(bounds: RectF, rotationDegrees: Float): RectF { + if (rotationDegrees == 0f) return this + val radians = Math.toRadians(rotationDegrees.toDouble()) + val cosValue = cos(radians).toFloat() + val sinValue = sin(radians).toFloat() + val cx = bounds.centerX() + val cy = bounds.centerY() + val points = arrayOf( + left to top, + right to top, + right to bottom, + left to bottom + ).map { (x, y) -> + val dx = x - cx + val dy = y - cy + PointF(cx + dx * cosValue - dy * sinValue, cy + dx * sinValue + dy * cosValue) + } + return RectF( + points.minOf { it.x }, + points.minOf { it.y }, + points.maxOf { it.x }, + points.maxOf { it.y } + ) +} + +private fun layoutTableCells(table: PptxTableElement): List { + if (table.rows.isEmpty() || table.bounds.width() <= 0f || table.bounds.height() <= 0f) return emptyList() + val explicitHeight = table.rows + .mapNotNull { it.heightPoint?.takeIf { h -> h > 0f } } + .sumOf { it.toDouble() } + .toFloat() + val missingRows = table.rows.count { it.heightPoint == null || it.heightPoint <= 0f } + val fallbackHeight = if (missingRows > 0) { + ((table.bounds.height() - explicitHeight).coerceAtLeast(1f)) / missingRows + } else { + table.bounds.height() / table.rows.size + } + + val cells = mutableListOf() + var y = table.bounds.top + table.rows.forEach { row -> + val rowHeight = row.heightPoint?.takeIf { it > 0f } ?: fallbackHeight + val explicitWidth = row.cells + .mapNotNull { it.widthPoint?.takeIf { w -> w > 0f } } + .sumOf { it.toDouble() } + .toFloat() + val missingCells = row.cells.count { it.widthPoint == null || it.widthPoint <= 0f } + val fallbackWidth = if (missingCells > 0) { + ((table.bounds.width() - explicitWidth).coerceAtLeast(1f)) / missingCells + } else if (row.cells.isNotEmpty()) { + table.bounds.width() / row.cells.size + } else { + table.bounds.width() + } + var x = table.bounds.left + row.cells.forEach { cell -> + val cellWidth = cell.widthPoint?.takeIf { it > 0f } ?: fallbackWidth + cells += LaidOutTableCell( + rect = RectF(x, y, x + cellWidth, y + rowHeight), + cell = cell + ) + x += cellWidth + } + y += rowHeight + } + return cells +} + +private inline fun Canvas.withRotation(bounds: RectF, rotationDegrees: Float, block: () -> Unit) { + withSave { + try { + if (rotationDegrees != 0f) { + rotate(rotationDegrees, bounds.centerX(), bounds.centerY()) + } + block() + } finally { + } + } +} + +private fun drawPresetShape( + canvas: Canvas, + bounds: RectF, + preset: String, + customGeometry: PptxCustomGeometry?, + paint: Paint +) { + if (customGeometry != null && preset != "line") { + canvas.drawPath(customGeometry.toPath(bounds), paint) + return + } + when (preset) { + "line" -> canvas.drawLine(bounds.left, bounds.top, bounds.right, bounds.bottom, paint) + "ellipse" -> canvas.drawOval(bounds, paint) + "roundrect", "roundRect" -> canvas.drawRoundRect(bounds, bounds.width() * 0.08f, bounds.height() * 0.08f, paint) + "triangle" -> canvas.drawPath(Path().apply { + moveTo(bounds.centerX(), bounds.top) + lineTo(bounds.right, bounds.bottom) + lineTo(bounds.left, bounds.bottom) + close() + }, paint) + "diamond" -> canvas.drawPath(Path().apply { + moveTo(bounds.centerX(), bounds.top) + lineTo(bounds.right, bounds.centerY()) + lineTo(bounds.centerX(), bounds.bottom) + lineTo(bounds.left, bounds.centerY()) + close() + }, paint) + else -> canvas.drawRect(bounds, paint) + } +} + +private fun PptxGradientFill.toShader(bounds: RectF): Shader { + val radians = Math.toRadians(angleDegrees.toDouble()) + val dx = cos(radians).toFloat() * bounds.width() + val dy = sin(radians).toFloat() * bounds.height() + return LinearGradient( + bounds.centerX() - dx / 2f, + bounds.centerY() - dy / 2f, + bounds.centerX() + dx / 2f, + bounds.centerY() + dy / 2f, + startColor, + endColor, + Shader.TileMode.CLAMP + ) +} + +private fun Element.textDefaults(theme: PptxTheme): PptxTextDefaults { + val txStyles = firstByLocalTag("txStyles") ?: return PptxTextDefaults() + return PptxTextDefaults( + title = txStyles.childrenByLocalTag("titleStyle").firstOrNull()?.paragraphStyles(theme).orEmpty(), + body = txStyles.childrenByLocalTag("bodyStyle").firstOrNull()?.paragraphStyles(theme).orEmpty(), + other = txStyles.childrenByLocalTag("otherStyle").firstOrNull()?.paragraphStyles(theme).orEmpty() + ) +} + +private fun Element.paragraphStyles(theme: PptxTheme): Map { + return children().mapNotNull { child -> + val tag = child.localTag() + val level = when { + tag == "defppr" -> 0 + tag.startsWith("lvl") && tag.endsWith("ppr") -> { + tag.removePrefix("lvl").removeSuffix("ppr").toIntOrNull()?.minus(1) + } + else -> null + } ?: return@mapNotNull null + level.coerceAtLeast(0) to child.paragraphStyle(theme) + }.toMap() +} + +private fun Element.paragraphStyle(theme: PptxTheme): PptxParagraphStyle { + val autoNumber = firstDirectByLocalTag("buAutoNum") + val bulletTypeface = firstDirectByLocalTag("buFont")?.xmlAttr("typeface") + val bullet = when { + firstDirectByLocalTag("buNone") != null -> null + autoNumber != null -> null + else -> firstDirectByLocalTag("buChar")?.xmlAttr("char")?.normalizeBulletGlyph(bulletTypeface) + } + return PptxParagraphStyle( + alignment = when (xmlAttr("algn")) { + "l", "just", "justLow", "dist", "thaiDist" -> PptxTextAlign.START + "ctr" -> PptxTextAlign.CENTER + "r" -> PptxTextAlign.END + else -> null + }, + bullet = bullet, + autoNumberType = autoNumber?.xmlAttr("type"), + autoNumberStartAt = autoNumber?.xmlInt("startAt"), + bulletExplicit = hasBulletDefinition(), + marginLeftPt = xmlFloat("marL")?.emuToPoint(), + indentPt = xmlFloat("indent")?.emuToPoint(), + spaceBeforePt = firstDirectByLocalTag("spcBef")?.spacingPoints(), + spaceAfterPt = firstDirectByLocalTag("spcAft")?.spacingPoints(), + lineSpacingMultiple = firstDirectByLocalTag("lnSpc")?.spacingMultiple(), + run = childrenByLocalTag("defRPr").firstOrNull()?.runStyle(theme) ?: PptxRunStyle() + ) +} + +private fun Element.runStyle(theme: PptxTheme): PptxRunStyle { + return PptxRunStyle( + sizePt = xmlFloat("sz")?.let { it / 100f }, + color = solidFillColor(theme), + bold = xmlAttr("b")?.isTruthyXmlFlag(), + italic = xmlAttr("i")?.isTruthyXmlFlag(), + typeface = typefaceName(theme), + baseline = xmlFloat("baseline")?.let { it / 100_000f } + ) +} + +private fun PptxParagraphStyle.resolvedBullet( + level: Int, + counters: IntArray, + counterStarted: BooleanArray +): String? { + val numberType = autoNumberType + if (numberType != null) { + val index = level.coerceIn(0, MAX_NUMBERING_LEVELS - 1) + if (!counterStarted[index]) { + counters[index] = (autoNumberStartAt ?: 1).coerceAtLeast(1) + counterStarted[index] = true + } else { + counters[index] += 1 + } + for (resetIndex in index + 1 until MAX_NUMBERING_LEVELS) { + counterStarted[resetIndex] = false + counters[resetIndex] = 0 + } + return formatAutoNumber(counters[index], numberType) + } + return bullet +} + +private fun formatAutoNumber(number: Int, type: String): String { + val normalized = type.lowercase(Locale.ROOT) + val value = when { + normalized.startsWith("alphalc") -> number.toAlphabeticLabel().lowercase(Locale.ROOT) + normalized.startsWith("alphauc") -> number.toAlphabeticLabel() + normalized.startsWith("romanlc") -> number.toRomanNumeral().lowercase(Locale.ROOT) + normalized.startsWith("romanuc") -> number.toRomanNumeral() + else -> number.toString() + } + return when { + "parenboth" in normalized -> "($value)" + "parenr" in normalized -> "$value)" + "period" in normalized -> "$value." + else -> value + } +} + +private fun Int.toAlphabeticLabel(): String { + var value = coerceAtLeast(1) + val result = StringBuilder() + while (value > 0) { + value -= 1 + result.insert(0, ('A'.code + (value % 26)).toChar()) + value /= 26 + } + return result.toString() +} + +private fun Int.toRomanNumeral(): String { + var value = coerceIn(1, 3999) + val numerals = listOf( + 1000 to "M", + 900 to "CM", + 500 to "D", + 400 to "CD", + 100 to "C", + 90 to "XC", + 50 to "L", + 40 to "XL", + 10 to "X", + 9 to "IX", + 5 to "V", + 4 to "IV", + 1 to "I" + ) + return buildString { + numerals.forEach { (amount, numeral) -> + while (value >= amount) { + append(numeral) + value -= amount + } + } + } +} + +private fun String.normalizeBulletGlyph(typeface: String?): String { + val family = typeface.orEmpty().lowercase(Locale.ROOT) + if ("wingdings" !in family && "symbol" !in family) return this + return when (this) { + "\u00A7", "\u00D8", "\u00B7", "\uF0B7" -> "\u2022" + "\u00FC", "\uF0FC" -> "\u2713" + "\u00A8", "\uF0A8" -> "\u25E6" + else -> this + } +} + +private fun Map.mergeStyles( + overrides: Map +): Map { + if (isEmpty()) return overrides + if (overrides.isEmpty()) return this + return buildMap { + putAll(this@mergeStyles) + overrides.forEach { (level, style) -> + put(level, this@mergeStyles[level]?.merge(style) ?: style) + } + } +} + +private fun scaledTextSize(sizePt: Float?, fontScale: Float): Int { + return ((sizePt ?: DEFAULT_TEXT_SIZE_PT) * fontScale.coerceIn(0.4f, 2f)) + .roundToInt() + .coerceAtLeast(1) +} + +private fun Element.imageCrop(): PptxImageCrop { + return PptxImageCrop( + left = xmlFloat("l")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, + top = xmlFloat("t")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, + right = xmlFloat("r")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, + bottom = xmlFloat("b")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f + ) +} + +private fun Element.imageOpacity(): Float { + firstByLocalTag("alphaModFix")?.xmlFloat("amt")?.let { return (it / 100_000f).coerceIn(0f, 1f) } + firstByLocalTag("alphaMod")?.xmlFloat("amt")?.let { return (it / 100_000f).coerceIn(0f, 1f) } + firstByLocalTag("alpha")?.xmlFloat("val")?.let { return (it / 100_000f).coerceIn(0f, 1f) } + return 1f +} + +private fun PptxImageCrop.sourceRect(width: Int, height: Int): Rect { + val leftPx = (width * left).roundToInt().coerceIn(0, width - 1) + val topPx = (height * top).roundToInt().coerceIn(0, height - 1) + val rightPx = (width * (1f - right)).roundToInt().coerceIn(leftPx + 1, width) + val bottomPx = (height * (1f - bottom)).roundToInt().coerceIn(topPx + 1, height) + return Rect(leftPx, topPx, rightPx, bottomPx) +} + +private fun Element.customGeometry(): PptxCustomGeometry? { + val paths = firstByLocalTag("pathLst")?.childrenByLocalTag("path").orEmpty() + if (paths.isEmpty()) return null + val width = paths.first().xmlFloat("w")?.takeIf { it > 0f } ?: return null + val height = paths.first().xmlFloat("h")?.takeIf { it > 0f } ?: return null + val commands = paths.flatMap { path -> + path.children().mapNotNull { command -> + when (command.localTag()) { + "moveto" -> command.firstDirectByLocalTag("pt")?.pathPoint()?.let { PptxPathCommand.MoveTo(it.x, it.y) } + "lnto" -> command.firstDirectByLocalTag("pt")?.pathPoint()?.let { PptxPathCommand.LineTo(it.x, it.y) } + "quadbezto" -> { + val points = command.childrenByLocalTag("pt").mapNotNull { it.pathPoint() } + if (points.size >= 2) { + PptxPathCommand.QuadTo(points[0].x, points[0].y, points[1].x, points[1].y) + } else { + null + } + } + "cubicbezto" -> { + val points = command.childrenByLocalTag("pt").mapNotNull { it.pathPoint() } + if (points.size >= 3) { + PptxPathCommand.CubicTo( + points[0].x, + points[0].y, + points[1].x, + points[1].y, + points[2].x, + points[2].y + ) + } else { + null + } + } + "close" -> PptxPathCommand.Close + else -> null + } + } + } + return PptxCustomGeometry(width = width, height = height, commands = commands) + .takeIf { it.commands.isNotEmpty() } +} + +private fun Element.pathPoint(): PointF? { + val x = xmlFloat("x") ?: return null + val y = xmlFloat("y") ?: return null + return PointF(x, y) +} + +private fun Element.gradientFill(theme: PptxTheme): PptxGradientFill? { + val gradFill = childrenByLocalTag("gradFill").firstOrNull() ?: return null + val stops = gradFill.firstByLocalTag("gsLst") + ?.childrenByLocalTag("gs") + ?.mapNotNull { stop -> stop.solidLikeColor(theme)?.let { stop.xmlInt("pos").orZero() to it } } + ?.sortedBy { it.first } + .orEmpty() + if (stops.size < 2) return null + val angle = gradFill.firstByLocalTag("lin")?.xmlFloat("ang")?.let { it / 60_000f } ?: 0f + return PptxGradientFill( + startColor = stops.first().second, + endColor = stops.last().second, + angleDegrees = angle + ) +} + +private fun Element.solidLikeColor(theme: PptxTheme): Int? { + firstByLocalTag("srgbClr")?.let { color -> + return color.xmlAttr("val")?.toColorOrNull()?.applyLuminance(color) + } + firstByLocalTag("schemeClr")?.let { color -> + val scheme = color.xmlAttr("val") ?: return null + return theme.color(scheme)?.applyLuminance(color) + } + firstByLocalTag("prstClr")?.let { color -> + return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) + } + firstByLocalTag("sysClr")?.let { color -> + return color.xmlAttr("lastClr")?.toColorOrNull()?.applyLuminance(color) + } + return null +} + +private fun Element.textInsets(): PptxTextInsets { + return PptxTextInsets( + left = (xmlFloat("lIns") ?: xmlFloat("marL"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, + top = (xmlFloat("tIns") ?: xmlFloat("marT"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, + right = (xmlFloat("rIns") ?: xmlFloat("marR"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, + bottom = (xmlFloat("bIns") ?: xmlFloat("marB"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT + ) +} + +private fun Element.verticalAnchor(): PptxVerticalAnchor { + return when (xmlAttr("anchor")) { + "ctr" -> PptxVerticalAnchor.MIDDLE + "b" -> PptxVerticalAnchor.BOTTOM + else -> PptxVerticalAnchor.TOP + } +} + +private fun Element.tableCellLineColor(theme: PptxTheme): Int? { + val line = children().firstNotNullOfOrNull { child -> + when (child.localTag()) { + "lnl", "lnr", "lnt", "lnb", "ln" -> child + "left", "right", "top", "bottom", "insideh", "insidev" -> child.firstDirectByLocalTag("ln") + else -> null + } + } + return line?.solidFillColor(theme) +} + +private fun Element.tableStylePart(theme: PptxTheme): PptxTableCellStyle { + val tcStyle = childrenByLocalTag("tcStyle").firstOrNull() + return PptxTableCellStyle( + fillColor = tcStyle?.firstDirectByLocalTag("fill")?.solidFillColor(theme), + lineColor = tcStyle?.firstDirectByLocalTag("tcBdr")?.tableCellLineColor(theme), + run = childrenByLocalTag("tcTxStyle").firstOrNull()?.tableTextRunStyle(theme) ?: PptxRunStyle() + ) +} + +private fun Element.tableTextRunStyle(theme: PptxTheme): PptxRunStyle { + return PptxRunStyle( + color = solidLikeColor(theme), + bold = xmlAttr("b")?.isTruthyXmlFlag(), + italic = xmlAttr("i")?.isTruthyXmlFlag(), + typeface = typefaceName(theme) + ) +} + +private fun Element.spacingPoints(): Float? { + firstByLocalTag("spcPts")?.xmlFloat("val")?.let { return it / 100f } + firstByLocalTag("spcPct")?.xmlFloat("val")?.let { return DEFAULT_TEXT_SIZE_PT * (it / 100_000f) } + return null +} + +private fun Element.spacingMultiple(): Float? { + firstByLocalTag("spcPct")?.xmlFloat("val")?.let { return it / 100_000f } + return null +} + +private fun Element.autoFitFontScale(): Float? { + return firstDirectByLocalTag("normAutofit") + ?.xmlFloat("fontScale") + ?.let { it / 100_000f } + ?.coerceIn(0.4f, 2f) +} + +private fun Element.autoFitLineSpacingReduction(): Float? { + return firstDirectByLocalTag("normAutofit") + ?.xmlFloat("lnSpcReduction") + ?.let { it / 100_000f } + ?.coerceIn(0f, 0.5f) +} + +private fun Element.autoFitMode(): PptxAutoFitMode { + return when { + firstDirectByLocalTag("normAutofit") != null -> PptxAutoFitMode.NORMAL + firstDirectByLocalTag("spAutoFit") != null -> PptxAutoFitMode.SHAPE + else -> PptxAutoFitMode.NONE + } +} + +private fun Element.typefaceName(theme: PptxTheme): String? { + val raw = firstByLocalTag("latin")?.xmlAttr("typeface") + ?: firstByLocalTag("ea")?.xmlAttr("typeface") + ?: firstByLocalTag("cs")?.xmlAttr("typeface") + val resolved = when { + raw == null -> null + raw.startsWith("+mj") -> theme.majorTypeface ?: raw + raw.startsWith("+mn") -> theme.minorTypeface ?: raw + else -> raw + } + return resolved?.takeIf { it.isNotBlank() } +} + +private fun Element.boundsFromTransform(): RectF { + val xfrm = childrenByLocalTag("xfrm").firstOrNull() ?: firstByLocalTag("xfrm") + val off = xfrm?.childrenByLocalTag("off")?.firstOrNull() + val ext = xfrm?.childrenByLocalTag("ext")?.firstOrNull() + val x = off?.xmlFloat("x")?.emuToPoint() ?: 0f + val y = off?.xmlFloat("y")?.emuToPoint() ?: 0f + val cx = ext?.xmlFloat("cx")?.emuToPoint() ?: 0f + val cy = ext?.xmlFloat("cy")?.emuToPoint() ?: 0f + return RectF(x, y, x + cx, y + cy) +} + +private fun Float.emuToPoint(): Float = this / EMU_PER_POINT + +private fun Float.emuToPointInt(): Int = emuToPoint().roundToInt().coerceAtLeast(1) + +private fun Int?.orZero(): Int = this ?: 0 + +private fun Element.rotationDegreesFromTransform(): Float { + val xfrm = childrenByLocalTag("xfrm").firstOrNull() ?: firstByLocalTag("xfrm") + return xfrm?.xmlFloat("rot")?.let { it / 60_000f } ?: 0f +} + +private fun Element.solidFillColor(theme: PptxTheme): Int? { + val solid = childrenByLocalTag("solidFill").firstOrNull() ?: return null + solid.firstByLocalTag("srgbClr")?.let { color -> + return color.xmlAttr("val")?.toColorOrNull()?.applyLuminance(color) + } + solid.firstByLocalTag("schemeClr")?.let { color -> + val scheme = color.xmlAttr("val") ?: return null + return theme.color(scheme)?.applyLuminance(color) + } + solid.firstByLocalTag("prstClr")?.let { color -> + return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) + } + solid.firstByLocalTag("sysClr")?.let { color -> + return color.xmlAttr("lastClr")?.toColorOrNull()?.applyLuminance(color) + } + return null +} + +private fun Element.schemeColor(theme: PptxTheme): Int? { + firstByLocalTag("schemeClr")?.let { color -> + val scheme = color.xmlAttr("val") ?: return null + return theme.color(scheme)?.applyLuminance(color) + } + firstByLocalTag("prstClr")?.let { color -> + return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) + } + return xmlAttr("idx")?.let { theme.color(it) } +} + +private fun Int.applyLuminance(colorElement: Element): Int { + val shade = colorElement.firstByLocalTag("shade")?.xmlFloat("val")?.let { it / 100_000f } + val tint = colorElement.firstByLocalTag("tint")?.xmlFloat("val")?.let { it / 100_000f } + val mod = colorElement.firstByLocalTag("lumMod")?.xmlFloat("val")?.let { it / 100_000f } ?: 1f + val off = colorElement.firstByLocalTag("lumOff")?.xmlFloat("val")?.let { it / 100_000f } ?: 0f + val alpha = colorElement.firstByLocalTag("alpha")?.xmlFloat("val")?.let { it / 100_000f } ?: 1f + fun channel(value: Int): Int { + var next = value.toFloat() + shade?.let { next *= it } + tint?.let { next += (255f - next) * it } + next = (next * mod) + (255f * off) + return next.roundToInt().coerceIn(0, 255) + } + return Color.argb( + (Color.alpha(this) * alpha).roundToInt().coerceIn(0, 255), + channel(Color.red(this)), + channel(Color.green(this)), + channel(Color.blue(this)) + ) +} + +private fun String.toColorOrNull(): Int? { + val clean = trim().removePrefix("#") + if (clean.length != 6) return null + return runCatching { Color.rgb(clean.substring(0, 2).toInt(16), clean.substring(2, 4).toInt(16), clean.substring(4, 6).toInt(16)) }.getOrNull() +} + +private fun String.presetColorOrNull(): Int? { + return when (lowercase(Locale.ROOT)) { + "black" -> Color.BLACK + "white" -> Color.WHITE + "red" -> Color.RED + "green" -> Color.GREEN + "blue" -> Color.BLUE + "yellow" -> Color.YELLOW + "cyan" -> Color.CYAN + "magenta" -> Color.MAGENTA + "gray", "grey" -> Color.GRAY + "dkgray", "dkgrey" -> Color.DKGRAY + "ltgray", "ltgrey" -> Color.LTGRAY + "orange" -> Color.rgb(255, 165, 0) + "purple" -> Color.rgb(128, 0, 128) + "brown" -> Color.rgb(165, 42, 42) + else -> null + } +} + +private fun File.contentHash(): String { + return runCatching { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + }.getOrElse { + Timber.w(it, "Falling back to path-based PPTX cache key") + "${canonicalPath}:${lastModified()}" + } +} + +private fun String?.isTruthyXmlFlag(): Boolean { + return this == "1" || equals("true", ignoreCase = true) || equals("on", ignoreCase = true) +} + +private fun String.placeholderFamily(): String { + return when (this.lowercase(Locale.ROOT)) { + "ctrtitle" -> "title" + "subttl" -> "subtitle" + else -> this.lowercase(Locale.ROOT) + } +} + +private fun Element.hasBulletDefinition(): Boolean { + return firstDirectByLocalTag("buNone") != null || + firstDirectByLocalTag("buChar") != null || + firstDirectByLocalTag("buAutoNum") != null +} + +private fun Element.hasTextColor(): Boolean { + return firstDirectByLocalTag("solidFill") != null || + firstDirectByLocalTag("gradFill") != null || + firstDirectByLocalTag("noFill") != null +} + +private fun Element.hasTypeface(): Boolean { + return firstByLocalTag("latin") != null || + firstByLocalTag("ea") != null || + firstByLocalTag("cs") != null +} + +private fun Element.localTag(): String = tagName().substringAfter(':').lowercase(Locale.ROOT) + +private fun Element.xmlAttr(name: String): String? { + if (":" in name) { + return attributes().asList() + .firstOrNull { it.key.equals(name, ignoreCase = true) } + ?.value + ?.takeIf { it.isNotBlank() } + } + val expectedLocal = name.substringAfter(':') + for (attribute in attributes().asList()) { + val key = attribute.key + if (key.equals(name, ignoreCase = true) || key.substringAfter(':').equals(expectedLocal, ignoreCase = true)) { + return attribute.value.takeIf { it.isNotBlank() } + } + } + return null +} + +private fun Element.xmlInt(name: String): Int? = xmlAttr(name)?.toIntOrNull() +private fun Element.xmlFloat(name: String): Float? = xmlAttr(name)?.toFloatOrNull() + +private fun Element.placeholderKey(): PptxPlaceholderKey { + return PptxPlaceholderKey( + type = xmlAttr("type")?.lowercase(Locale.ROOT), + index = xmlAttr("idx") + ) +} + +private fun PptxPlaceholderKey.matches(other: PptxPlaceholderKey): Boolean { + if (index != null && other.index != null && index == other.index) return true + if (type != null && other.type != null && type.placeholderFamily() == other.type.placeholderFamily()) return true + return index == null && other.index == null && type == null && other.type == null +} + +private fun Element.childrenByLocalTag(tag: String): List { + val local = tag.lowercase(Locale.ROOT) + return children().filter { it.localTag() == local } +} + +private fun Element.firstDirectByLocalTag(tag: String): Element? = childrenByLocalTag(tag).firstOrNull() + +private fun Element.firstByLocalTag(tag: String): Element? { + val local = tag.lowercase(Locale.ROOT) + return allElements.firstOrNull { it.localTag() == local } +} + +private fun Element.allByLocalTag(tag: String): List { + val local = tag.lowercase(Locale.ROOT) + return allElements.filter { it.localTag() == local } +} + +private fun String.relationshipsPath(): String { + val dir = substringBeforeLast('/', missingDelimiterValue = "") + val name = substringAfterLast('/') + return if (dir.isBlank()) "_rels/$name.rels" else "$dir/_rels/$name.rels" +} + +private fun resolveRelationshipTarget(partPath: String, target: String, targetMode: String?): String { + if (targetMode.equals("External", ignoreCase = true)) return target + val cleanTarget = target.substringBefore('#').removePrefix("/") + val base = partPath.substringBeforeLast('/', missingDelimiterValue = "") + return normalizePartPath(if (target.startsWith("/")) cleanTarget else "$base/$cleanTarget") +} + +private fun normalizePartPath(path: String): String { + val clean = path.removePrefix("/") + val parts = ArrayDeque() + clean.split('/').forEach { part -> + when (part) { + "", "." -> Unit + ".." -> { + if (parts.isNotEmpty()) parts.removeLast() + } + else -> parts.addLast(part) + } + } + return parts.joinToString("/") +} + +private fun String.imageContentType(): String { + return when (substringAfterLast('.', "").lowercase(Locale.ROOT)) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "bmp" -> "image/bmp" + "svg" -> "image/svg+xml" + else -> "application/octet-stream" + } +} + +private fun naturalSlidePathComparator(): Comparator { + return compareBy { path -> + Regex("""slide(\d+)\.xml""").find(path)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE + } +} diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index f6af48b..907731a 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -83,7 +83,9 @@ private const val PREFETCH_LOOKAHEAD = 3 class TtsPlaybackManager( private val player: Player, private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData, - private val onResetContext: () -> Unit + private val onResetContext: () -> Unit, + private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> }, + private val onPlaybackSessionStopped: () -> Unit = {} ) : MediaSession.Callback, Player.Listener { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -415,6 +417,8 @@ class TtsPlaybackManager( handleStopTts(clearState = false) } + onPlaybackSessionPreparing(bookTitle, chapterTitle) + textChunks = chunks currentSpeakerId = speakerId currentTtsMode = ttsMode @@ -550,6 +554,7 @@ class TtsPlaybackManager( val firstChunk = textChunks.getOrNull(startAtIndex) if (firstChunk == null) { _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.") + onPlaybackSessionStopped() return } @@ -627,6 +632,7 @@ class TtsPlaybackManager( isLoading = false, errorMessage = ttsAudioData.error ?: "Failed to load audio." ) + onPlaybackSessionStopped() } } @@ -667,6 +673,7 @@ class TtsPlaybackManager( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "handleStopTts. clearState=$clearState, userInitiated=$userInitiated" ) + onPlaybackSessionStopped() onResetContext() preparationJob?.cancel() wordTrackingJob?.cancel() diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/aryan/reader/tts/TtsService.kt index 5558ed3..22b9a99 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsService.kt @@ -20,9 +20,15 @@ package com.aryan.reader.tts import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.os.Build +import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -30,13 +36,16 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import com.aryan.reader.R import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.loadAiByokSettings import com.aryan.reader.tts.TtsPlaybackManager.TtsMode +import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.json.JSONObject import timber.log.Timber @@ -229,6 +238,11 @@ class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) { } } +private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback" +// Keep this aligned with Media3's default notification ID so playback updates replace the fallback. +private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001 +private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L + @UnstableApi class TtsService : MediaSessionService() { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -237,6 +251,24 @@ class TtsService : MediaSessionService() { private lateinit var playbackManager: TtsPlaybackManager private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer private lateinit var cacheManager: TtsCacheManager + private var foregroundNotificationShown = false + private var foregroundPlaybackExpected = false + private var foregroundIdleJob: Job? = null + private var foregroundBookTitle: String? = null + private var foregroundChapterTitle: String? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val result = super.onStartCommand(intent, flags, startId) + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( + "onStartCommand. action=${intent?.action}, startId=$startId, result=$result" + ) + val hasPreparedMedia = ::player.isInitialized && player.mediaItemCount > 0 + if (!hasPreparedMedia) { + showPreparingForegroundNotification("onStartCommand") + scheduleForegroundIdleStop(startId) + } + return result + } override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || @@ -252,21 +284,133 @@ class TtsService : MediaSessionService() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - - if (startInForegroundRequired) { - Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().") - stopSelf() - } else { - Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.") - } - - return + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w( + "POST_NOTIFICATIONS is missing, but MediaSession notifications are exempt. Delegating notification update." + ) } Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.") super.onUpdateNotification(session, startInForegroundRequired) } + private fun showPreparingForegroundNotification( + reason: String, + bookTitle: String? = foregroundBookTitle, + chapterTitle: String? = foregroundChapterTitle + ) { + foregroundBookTitle = bookTitle + foregroundChapterTitle = chapterTitle + ensureTtsNotificationChannel() + + try { + val notification = buildPreparingForegroundNotification(bookTitle, chapterTitle) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + TTS_FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(TTS_FOREGROUND_NOTIFICATION_ID, notification) + } + foregroundNotificationShown = true + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Fallback foreground notification shown. reason=$reason") + } catch (e: Exception) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "Failed to show fallback foreground notification. reason=$reason") + stopSelf() + } + } + + private fun buildPreparingForegroundNotification(bookTitle: String?, chapterTitle: String?): Notification { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + val contentIntent = launchIntent?.let { + PendingIntent.getActivity(this, 0, it, pendingIntentFlags()) + } + val title = bookTitle?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name) + val text = chapterTitle?.takeIf { it.isNotBlank() } + ?.let { getString(R.string.tts_notification_preparing_chapter, it) } + ?: getString(R.string.tts_notification_preparing) + + return NotificationCompat.Builder(this, TTS_FOREGROUND_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(title) + .setContentText(text) + .setOngoing(true) + .setSilent(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .apply { + if (contentIntent != null) { + setContentIntent(contentIntent) + } + } + .build() + } + + private fun ensureTtsNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + TTS_FOREGROUND_CHANNEL_ID, + getString(R.string.tts_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.tts_notification_channel_desc) + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + + private fun pendingIntentFlags(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } + + private fun scheduleForegroundIdleStop(startId: Int) { + foregroundIdleJob?.cancel() + foregroundIdleJob = scope.launch { + delay(TTS_FOREGROUND_IDLE_GRACE_MS) + val playbackInactive = !::player.isInitialized || + (!player.isPlaying && !player.playWhenReady && player.mediaItemCount == 0) + if (!foregroundPlaybackExpected && playbackInactive) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w( + "Foreground service start did not become an active TTS session. Stopping fallback foreground." + ) + stopTtsForeground() + stopSelf(startId) + } + } + } + + private fun onPlaybackSessionPreparing(bookTitle: String?, chapterTitle: String?) { + foregroundPlaybackExpected = true + foregroundIdleJob?.cancel() + showPreparingForegroundNotification("START_TTS_COMMAND", bookTitle, chapterTitle) + } + + private fun onPlaybackSessionStopped() { + foregroundPlaybackExpected = false + foregroundIdleJob?.cancel() + foregroundBookTitle = null + foregroundChapterTitle = null + stopTtsForeground() + } + + private fun stopTtsForeground() { + if (!foregroundNotificationShown) return + try { + stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE) + } catch (e: Exception) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(e, "Failed to stop fallback foreground notification.") + } finally { + foregroundNotificationShown = false + } + } + private val okHttpClient = OkHttpClient.Builder().build() private val liveClient by lazy { GeminiLiveClient(okHttpClient) { errorMsg -> @@ -668,7 +812,9 @@ class TtsService : MediaSessionService() { playbackManager = TtsPlaybackManager( player = player, generateAudioChunk = audioGenerator, - onResetContext = { liveClient.close() } + onResetContext = { liveClient.close() }, + onPlaybackSessionPreparing = ::onPlaybackSessionPreparing, + onPlaybackSessionStopped = ::onPlaybackSessionStopped ) mediaSession = MediaSession.Builder(this, player) @@ -683,7 +829,7 @@ class TtsService : MediaSessionService() { Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}" ) - if (!player.playWhenReady) { + if (!::player.isInitialized || !player.playWhenReady) { Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().") stopSelf() } @@ -700,13 +846,26 @@ class TtsService : MediaSessionService() { override fun onDestroy() { Timber.d("TtsService is being destroyed.") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.") - baseTtsSynthesizer.shutdown() - playbackManager.release() - mediaSession?.run { - player.release() - release() + foregroundIdleJob?.cancel() + stopTtsForeground() + if (::baseTtsSynthesizer.isInitialized) { + baseTtsSynthesizer.shutdown() + } + if (::playbackManager.isInitialized) { + playbackManager.release() + } + var playerReleased = false + mediaSession?.let { session -> + if (::player.isInitialized) { + player.release() + playerReleased = true + } + session.release() mediaSession = null } + if (!playerReleased && ::player.isInitialized) { + player.release() + } super.onDestroy() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af96739..f0ac387 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -508,6 +508,7 @@ Could not find recent item. Failed to import file. Could not find file location. + This file type is not supported. Failed to load generated text view. @@ -690,10 +691,10 @@ View Original PDF Delete Text View - - Reading Mode: Vertical - - Reading Mode: Paginated + + Vertical + + Paginated (left-to-right) Enabled Remove bookmark @@ -704,11 +705,17 @@ Realistic Page Turns Keep Screen On Visual Options + Screen Orientation + Change Reading Mode + Paginated (right-to-left) Auto Scroll + TTS Settings + TTS Voice Settings TTS Word Replacements + Share, Save or Print TTS Settings (Debug) Navigate with slider @@ -824,9 +831,16 @@ Import from Files No imported fonts yet. Visual Options + Page layout + Remove gap between pages + Applies to vertical reading mode. + Hide page number overlay + Removes the small page count label from each page. System UI (Status & Navigation Bars) Control the visibility of the device\'s system bars. + Screen Orientation + Choose whether the reader follows the system orientation or prefers portrait or landscape when Android allows it. Progress Bar The reading progress and chapter indicator on the reading screen. Position @@ -1164,6 +1178,11 @@ Files + Text to speech + Playback controls for text to speech. + Preparing text to speech + + Preparing: %1$s Active TTS Engine Cloud AI Device Native diff --git a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt index 0e5f22c..6f3c9d8 100644 --- a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt +++ b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt @@ -14,6 +14,7 @@ data class ProUpgradeState( val creditProducts: List = emptyList(), val hasValidPurchase: Boolean = false, val activePurchases: List = emptyList(), + val hasAccountConflict: Boolean = false, val billingClientReady: Boolean = false, val error: String? = null, val isVerifying: Boolean = false @@ -35,7 +36,11 @@ class BillingClientWrapper( // No-op } - fun launchPurchaseFlow(activity: Activity, productId: String = PRO_LIFETIME_PRODUCT_ID) { + fun launchPurchaseFlow( + activity: Activity, + productId: String = PRO_LIFETIME_PRODUCT_ID, + obfuscatedAccountId: String? = null + ) { _proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version") } fun consumePurchase(purchaseToken: String) {} @@ -44,6 +49,14 @@ class BillingClientWrapper( _proUpgradeState.value = _proUpgradeState.value.copy(error = null) } + fun markAccountConflict() { + _proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = true, isVerifying = false) + } + + fun clearAccountConflict() { + _proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = false) + } + fun clearVerificationState() { _proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false) } @@ -51,4 +64,4 @@ class BillingClientWrapper( companion object { const val PRO_LIFETIME_PRODUCT_ID = "episteme_pro_lifetime" } -} \ No newline at end of file +} 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 7a693d9..1b82a8c 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt @@ -23,8 +23,17 @@ data class BookMetadata( val lastModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, + val fileContentModifiedTimestamp: Long = 0L, val customName: String? = null, - val highlightsJson: String? = null + val highlightsJson: String? = null, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null ) data class DeviceItem( @@ -135,4 +144,4 @@ class FirestoreRepository { suspend fun addMessageToThread(threadId: String, messageId: String, uid: String, text: String, sender: String, attachments: List) {} suspend fun markThreadAsRead(threadId: String) {} -} \ No newline at end of file +} diff --git a/app/src/oss/res/values/strings.xml b/app/src/oss/res/values/strings.xml index 4aa2442..d94e77f 100644 --- a/app/src/oss/res/values/strings.xml +++ b/app/src/oss/res/values/strings.xml @@ -1,4 +1,4 @@ - Episteme (OSS) - \ No newline at end of file + Episteme oss + diff --git a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt new file mode 100644 index 0000000..8facafd --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt @@ -0,0 +1,195 @@ +package com.aryan.reader + +import com.aryan.reader.shared.SharedSettingsAction +import com.aryan.reader.shared.SharedSettingsDestination +import com.aryan.reader.shared.SharedSettingsHubModel +import com.aryan.reader.shared.SharedSettingsItemModel +import com.aryan.reader.shared.sharedSettingsHubModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidSettingsHubModelsTest { + + @Test + fun `offline android settings hide network backed sections`() { + val model = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(), + isOssBuild = true, + isOfflineBuild = true, + isDebugBuild = false + ) + ) + val actions = model.visibleNestedActions() + + assertFalse(SharedSettingsAction.AI_SETTINGS in actions) + assertFalse(SharedSettingsAction.HIDE_READER_AI in actions) + assertFalse(SharedSettingsAction.SIGN_IN in actions) + assertFalse(SharedSettingsAction.CLOUD_SYNC in actions) + assertFalse(SharedSettingsAction.FOLDER_SYNC in actions) + assertFalse(SharedSettingsAction.HELP_FEEDBACK in actions) + assertFalse(SharedSettingsAction.SUPPORT in actions) + assertTrue(SharedSettingsAction.TTS_SETTINGS in actions) + assertTrue(SharedSettingsAction.CUSTOM_FONTS in actions) + } + + @Test + fun `oss online settings hide sync rows but keep oss ai key settings`() { + val model = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState( + currentUser = UserData( + uid = "user-id", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" + ), + isProUser = true, + isSyncEnabled = true, + isFolderSyncEnabled = true + ), + isOssBuild = true, + isOfflineBuild = false, + isDebugBuild = true + ) + ) + val actions = model.visibleNestedActions() + + assertTrue(SharedSettingsAction.AI_SETTINGS in actions) + assertTrue(SharedSettingsAction.HIDE_READER_AI in actions) + assertFalse(SharedSettingsAction.SIGN_OUT in actions) + assertFalse(SharedSettingsAction.CLOUD_SYNC in actions) + assertFalse(SharedSettingsAction.FOLDER_SYNC in actions) + assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions) + assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions) + assertTrue(SharedSettingsAction.SUPPORT in actions) + assertEquals( + "TTS & AI", + model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title + ) + } + + @Test + fun `non oss settings do not expose oss ai key settings`() { + val model = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(isProUser = true), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = false + ) + ) + val actions = model.visibleNestedActions() + + assertFalse(SharedSettingsAction.AI_SETTINGS in actions) + assertTrue(SharedSettingsAction.HIDE_READER_AI in actions) + assertTrue(SharedSettingsAction.CLOUD_SYNC in actions) + assertFalse(SharedSettingsAction.SUPPORT in actions) + assertEquals( + "TTS", + model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title + ) + } + + @Test + fun `android settings expose debug-only storage actions only in debug`() { + val releaseActions = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = false + ) + ).visibleNestedActions() + val debugActions = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = true + ) + ).visibleNestedActions() + + assertFalse(SharedSettingsAction.EXPORT_LOGS in releaseActions) + assertTrue(SharedSettingsAction.EXPORT_LOGS in debugActions) + } + + @Test + fun `android settings reflect global toggles from reader state`() { + val model = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState( + isTabsEnabled = true, + useStrictFileFilter = true, + isScreenCaptureProtectionEnabled = true + ), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = false + ) + ) + val toggles = model.visibleNestedItems().associateBy { it.action } + + assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true) + assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true) + assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true) + } + + @Test + fun `android extra settings expose home overflow actions without settings duplicate`() { + val model = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = true + ) + ) + val extraActions = model.page(SharedSettingsDestination.EXTRA).items.map { it.action } + + assertTrue(SharedSettingsAction.TABS_TOGGLE in extraActions) + assertTrue(SharedSettingsAction.LANGUAGE in extraActions) + assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions) + assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions) + assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions) + assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions) + assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions) + assertTrue(SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION in extraActions) + assertTrue(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in extraActions) + } + + @Test + fun `cloud sync row is gated by pro state`() { + val freeSync = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(isProUser = false), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = false + ) + ).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC } + val proSync = sharedSettingsHubModel( + androidSettingsHubInput( + uiState = ReaderScreenState(isProUser = true), + isOssBuild = false, + isOfflineBuild = false, + isDebugBuild = false + ) + ).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC } + + assertFalse(freeSync.enabled) + assertTrue(proSync.enabled) + } +} + +private fun SharedSettingsHubModel.visibleNestedItems(): List { + return rootCategories.flatMap { category -> + page(category.destination).items + } +} + +private fun SharedSettingsHubModel.visibleNestedActions(): List { + return visibleNestedItems().map { it.action } +} diff --git a/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt b/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt new file mode 100644 index 0000000..da1a270 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt @@ -0,0 +1,230 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookTagCrossRef +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity +import com.aryan.reader.shared.AppAction as SharedAppAction +import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode +import com.aryan.reader.shared.LibraryAction as SharedLibraryAction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidSharedStateBridgeTest { + + @Test + fun `prepareLibraryProjection builds shared input and Android lookup context`() { + val tag = tag("tag", "Favorite") + val book = recentFile("book", sourceFolderUri = "content://folder") + val reflowCopy = recentFile("book_reflow", sourceFolderUri = "content://folder") + + val context = AndroidSharedStateBridge.prepareLibraryProjection( + input = LibraryProjectionInput( + state = ReaderScreenState(), + recentFilesFromDb = listOf(book, reflowCopy), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = listOf(tag), + tagRefs = listOf(BookTagCrossRef(bookId = book.bookId, tagId = tag.id)) + ), + folderPathResolver = EmptyFolderPathResolver + ) + + assertEquals(listOf("book"), context.androidBooksById.keys.toList()) + assertEquals(listOf("book"), context.sharedInput.booksFromStore.map { it.id }) + assertEquals(listOf("tag"), context.sharedInput.booksFromStore.single().tags.map { it.id }) + assertEquals(listOf(AndroidSharedFolderProjectionKey("content://folder", "Local Folder")), context.folderKeys) + } + + @Test + fun `reduceLibraryAction applies shared library state back to Android fields`() { + val book = recentFile("book") + val filters = LibraryFilters(readStatus = ReadStatusFilter.COMPLETED) + + val selected = AndroidSharedStateBridge.reduceLibraryAction( + current = ReaderScreenState(), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)), + action = SharedLibraryAction.BookSelectionToggled(book.bookId) + ) + val filtered = AndroidSharedStateBridge.reduceLibraryAction( + current = selected, + projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)), + action = SharedLibraryAction.FiltersChanged(filters.toSharedLibraryFilters()) + ) + + assertEquals(setOf(book), selected.contextualActionItems) + assertEquals(filters, filtered.libraryFilters) + } + + @Test + fun `reduceLibraryAction drops selection ids that are not in projected Android books`() { + val result = AndroidSharedStateBridge.reduceLibraryAction( + current = ReaderScreenState(), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(recentFile("book"))), + action = SharedLibraryAction.BookSelectionToggled("missing") + ) + + assertTrue(result.contextualActionItems.isEmpty()) + } + + @Test + fun `reduceAppAction applies shared app state back to Android fields`() { + val result = AndroidSharedStateBridge.reduceAppAction( + current = ReaderScreenState(appThemeMode = AppThemeMode.LIGHT), + projectedState = ReaderScreenState(), + action = SharedAppAction.AppThemeChanged(SharedAppThemeMode.DARK) + ) + + assertEquals(AppThemeMode.DARK, result.appThemeMode) + } + + @Test + fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() { + val result = AndroidSharedStateBridge.setTabsEnabled( + current = ReaderScreenState( + isTabsEnabled = true, + openTabIds = listOf("one", "two"), + activeTabBookId = "two" + ), + projectedState = ReaderScreenState(), + enabled = false + ) + + assertEquals(false, result.isTabsEnabled) + assertEquals(listOf("two"), result.openTabIds) + assertEquals("two", result.activeTabBookId) + } + + @Test + fun `openBookTab delegates tab ordering and activation to shared reducer`() { + val result = AndroidSharedStateBridge.openBookTab( + current = ReaderScreenState( + isTabsEnabled = false, + openTabIds = listOf("old"), + activeTabBookId = "old" + ), + projectedState = ReaderScreenState(), + bookId = "new" + ) + + assertEquals(true, result.isTabsEnabled) + assertEquals(listOf("old", "new"), result.openTabIds) + assertEquals("new", result.activeTabBookId) + } + + @Test + fun `closeBookTab selects the previous tab when the active tab closes`() { + val result = AndroidSharedStateBridge.closeBookTab( + current = ReaderScreenState( + isTabsEnabled = true, + openTabIds = listOf("one", "two", "three"), + activeTabBookId = "three" + ), + projectedState = ReaderScreenState(), + bookId = "three" + ) + + assertEquals(true, result.isTabsEnabled) + assertEquals(listOf("one", "two"), result.openTabIds) + assertEquals("two", result.activeTabBookId) + } + + @Test + fun `closeAllTabs clears Android tab ids through shared reducer`() { + val result = AndroidSharedStateBridge.closeAllTabs( + current = ReaderScreenState( + isTabsEnabled = true, + openTabIds = listOf("one", "two"), + activeTabBookId = "two" + ), + projectedState = ReaderScreenState() + ) + + assertEquals(true, result.isTabsEnabled) + assertTrue(result.openTabIds.isEmpty()) + assertEquals(null, result.activeTabBookId) + } + + @Test + fun `togglePinsForSelectedBooks pins mixed home selection and clears selection`() { + val pinned = recentFile("pinned") + val unpinned = recentFile("unpinned") + + val result = AndroidSharedStateBridge.togglePinsForSelectedBooks( + current = ReaderScreenState( + rawLibraryFiles = listOf(pinned, unpinned), + contextualActionItems = setOf(pinned, unpinned), + pinnedHomeBookIds = setOf(pinned.bookId) + ), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(pinned, unpinned)), + isHome = true + ) + + assertEquals(setOf("pinned", "unpinned"), result.pinnedHomeBookIds) + assertTrue(result.contextualActionItems.isEmpty()) + } + + @Test + fun `togglePinsForSelectedBooks unpins when all selected library books are pinned`() { + val first = recentFile("first") + val second = recentFile("second") + + val result = AndroidSharedStateBridge.togglePinsForSelectedBooks( + current = ReaderScreenState( + rawLibraryFiles = listOf(first, second), + contextualActionItems = setOf(first, second), + pinnedLibraryBookIds = setOf(first.bookId, second.bookId) + ), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(first, second)), + isHome = false + ) + + assertTrue(result.pinnedLibraryBookIds.isEmpty()) + assertTrue(result.contextualActionItems.isEmpty()) + } + + @Test + fun `replaceBookSelectionWithVisibleBooks selects visible books through shared reducer`() { + val visible = recentFile("visible") + val hidden = recentFile("hidden") + + val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks( + current = ReaderScreenState(), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible, hidden)), + visibleBooks = listOf(visible) + ) + + assertEquals(setOf(visible), result.contextualActionItems) + } + + @Test + fun `replaceBookSelectionWithVisibleBooks clears when visible books are already selected`() { + val visible = recentFile("visible") + + val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks( + current = ReaderScreenState(contextualActionItems = setOf(visible)), + projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible)), + visibleBooks = listOf(visible) + ) + + assertTrue(result.contextualActionItems.isEmpty()) + } + + private fun recentFile( + id: String, + sourceFolderUri: String? = null + ) = RecentFileItem( + bookId = id, + uriString = "content://$id", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L, + sourceFolderUri = sourceFolderUri + ) + + private fun tag(id: String, name: String) = TagEntity( + id = id, + name = name, + createdAt = 1L + ) +} diff --git a/app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt b/app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt new file mode 100644 index 0000000..09c23d9 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/EmbeddedEbookMetadataExtractorTest.kt @@ -0,0 +1,215 @@ +package com.aryan.reader + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class EmbeddedEbookMetadataExtractorTest { + + @Test + fun `epub extracts text metadata and explicitly referenced cover image`() { + val coverBytes = onePixelPngBytes() + val epubBytes = zipBytes( + "META-INF/container.xml" to """ + + + + + + """.trimIndent().toByteArray(Charsets.UTF_8), + "OEBPS/content.opf" to """ + + + Folder EPUB + Octavia Butler + <p>Folder summary</p> + + + + + + + + + """.trimIndent().toByteArray(Charsets.UTF_8), + "OEBPS/images/cover.png" to coverBytes + ) + + val metadata = EmbeddedEbookMetadataExtractor.extract( + type = FileType.EPUB, + displayName = "folder.epub", + openStream = { ByteArrayInputStream(epubBytes) } + ) + + assertEquals("Folder EPUB", metadata.title) + assertEquals("Octavia Butler", metadata.author) + assertEquals("

Folder summary

", metadata.description) + assertEquals("Patternist", metadata.seriesName) + assertEquals(3.0, metadata.seriesIndex) + val cover = metadata.cover + assertNotNull(cover) + assertEquals("png", cover!!.extension) + assertArrayEquals(coverBytes, cover.bytes) + } + + @Test + fun `fb2 extracts coverpage binary without parsing book body`() { + val coverBytes = onePixelPngBytes() + val fb2 = """ + + + + Folder FB2 + + Ursula + Le Guin + + + + + +

Body text should not matter.

+ ${Base64.getEncoder().encodeToString(ByteArray(0))} + ${Base64.getEncoder().encodeToString(coverBytes)} +
+ """.trimIndent() + + val metadata = EmbeddedEbookMetadataExtractor.extract( + type = FileType.FB2, + displayName = "folder.fb2", + openStream = { ByteArrayInputStream(fb2.toByteArray(Charsets.UTF_8)) } + ) + + assertEquals("Folder FB2", metadata.title) + assertEquals("Ursula Le Guin", metadata.author) + val cover = metadata.cover + assertNotNull(cover) + assertEquals("png", cover!!.extension) + assertArrayEquals(coverBytes, cover.bytes) + } + + @Test + fun `mobi extracts EXTH text metadata and embedded cover record`() { + val coverBytes = onePixelPngBytes() + val mobiBytes = minimalMobiBytes( + title = "Folder MOBI", + author = "N K Jemisin", + coverBytes = coverBytes + ) + + val metadata = EmbeddedEbookMetadataExtractor.extract( + type = FileType.MOBI, + displayName = "folder.mobi", + openStream = { ByteArrayInputStream(mobiBytes) } + ) + + assertEquals("Folder MOBI", metadata.title) + assertEquals("N K Jemisin", metadata.author) + val cover = metadata.cover + assertNotNull(cover) + assertEquals("png", cover!!.extension) + assertArrayEquals(coverBytes, cover.bytes) + } + + private fun zipBytes(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return out.toByteArray() + } + + private fun minimalMobiBytes(title: String, author: String, coverBytes: ByteArray): ByteArray { + val exthRecords = listOf( + exthStringRecord(99, title), + exthStringRecord(100, author), + exthIntRecord(201, 0) + ) + val exthSize = 12 + exthRecords.sumOf { it.size } + val mobiHeaderLength = 232 + val record0 = ByteArray(16 + mobiHeaderLength + exthSize) + putU16(record0, 0, 1) + putU32(record0, 4, 0) + putU16(record0, 8, 0) + putU16(record0, 12, 0) + putAscii(record0, 16, "MOBI") + putU32(record0, 20, mobiHeaderLength) + putU32(record0, 16 + 12, 65001) + putU32(record0, 16 + 68, 0) + putU32(record0, 16 + 72, 0) + putU32(record0, 16 + 92, 1) + + val exthOffset = 16 + mobiHeaderLength + putAscii(record0, exthOffset, "EXTH") + putU32(record0, exthOffset + 4, exthSize) + putU32(record0, exthOffset + 8, exthRecords.size) + var cursor = exthOffset + 12 + exthRecords.forEach { record -> + record.copyInto(record0, cursor) + cursor += record.size + } + + val palmHeader = ByteArray(78 + 8 * 2) + putU16(palmHeader, 76, 2) + val record0Offset = palmHeader.size + val coverOffset = record0Offset + record0.size + putU32(palmHeader, 78, record0Offset) + putU32(palmHeader, 86, coverOffset) + + return palmHeader + record0 + coverBytes + } + + private fun exthStringRecord(type: Int, value: String): ByteArray { + val data = value.toByteArray(Charsets.UTF_8) + return exthRecord(type, data) + } + + private fun exthIntRecord(type: Int, value: Int): ByteArray { + val data = ByteArray(4) + putU32(data, 0, value) + return exthRecord(type, data) + } + + private fun exthRecord(type: Int, data: ByteArray): ByteArray { + val record = ByteArray(8 + data.size) + putU32(record, 0, type) + putU32(record, 4, record.size) + data.copyInto(record, 8) + return record + } + + private fun putAscii(target: ByteArray, offset: Int, value: String) { + value.toByteArray(Charsets.US_ASCII).copyInto(target, offset) + } + + private fun putU16(target: ByteArray, offset: Int, value: Int) { + target[offset] = ((value ushr 8) and 0xFF).toByte() + target[offset + 1] = (value and 0xFF).toByte() + } + + private fun putU32(target: ByteArray, offset: Int, value: Int) { + target[offset] = ((value ushr 24) and 0xFF).toByte() + target[offset + 1] = ((value ushr 16) and 0xFF).toByte() + target[offset + 2] = ((value ushr 8) and 0xFF).toByte() + target[offset + 3] = (value and 0xFF).toByte() + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt index b7a05ee..5464efd 100644 --- a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt +++ b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt @@ -2,6 +2,7 @@ package com.aryan.reader import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -20,6 +21,21 @@ class FileTypeResolverTest { assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv")) assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt")) assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt")) + assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt")) + } + + @Test + fun `metadata resolver maps provider mime types without exposing generic archives`() { + assertEquals(FileType.PDF, resolveFileTypeFromMetadata("download", "application/pdf")) + assertEquals(FileType.DOCX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")) + assertEquals(FileType.PPTX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.presentationml.presentation")) + assertEquals(FileType.MD, resolveFileTypeFromMetadata("notes.markdown.txt", "text/plain; charset=utf-8")) + assertEquals(FileType.EPUB, resolveFileTypeFromMetadata("book.epub.txt", "text/plain")) + assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain")) + assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json")) + assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip")) + assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip")) + assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip")) } @Test @@ -39,7 +55,9 @@ class FileTypeResolverTest { @Test fun `plain txt remains txt when inner extension is unsupported`() { assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt")) + assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx")) assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt")) + assertNull(resolveFileTypeFromName("archive.zip")) } @Test diff --git a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt index 2448ac6..21c5753 100644 --- a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt +++ b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt @@ -32,6 +32,13 @@ class LibraryStateProjectorTest { assertEquals(files.ids(), filterBySearch(files, " ").ids()) } + @Test + fun `filterBySearch preserves android display-name matching when a custom name exists`() { + val file = recentFile("custom", displayName = "Original File.pdf", customName = "Renamed") + + assertEquals(listOf("custom"), filterBySearch(listOf(file), "original").ids()) + } + @Test fun `applyLibraryFilters requires all active filters to match`() { val activeTag = tag("active", "Active") @@ -188,6 +195,7 @@ class LibraryStateProjectorTest { sortOrder = SortOrder.TITLE_ASC, recentFilesLimit = 1, openTabIds = listOf("beta", "missing"), + activeTabBookId = "missing", contextualActionItems = setOf(recentFile("beta"), recentFile("missing")), viewingShelfId = "manual", contextualActionShelfIds = setOf("manual", "missing") @@ -208,6 +216,8 @@ class LibraryStateProjectorTest { assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids()) assertEquals(listOf("beta"), result.recentFiles.ids()) assertEquals(listOf("beta"), result.openTabs.ids()) + assertEquals(listOf("beta"), result.openTabIds) + assertNull(result.activeTabBookId) assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId }) assertEquals(listOf(tag), result.contextualActionItems.first().tags) assertEquals("manual", result.viewingShelfId) @@ -266,6 +276,30 @@ class LibraryStateProjectorTest { assertEquals(listOf(tag), result.allTags) } + @Test + fun `project keeps pinned home and library books first using shared projector`() { + val older = recentFile("older", title = "Zulu", timestamp = 1L) + val newer = recentFile("newer", title = "Alpha", timestamp = 2L) + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState( + sortOrder = SortOrder.TITLE_ASC, + pinnedHomeBookIds = setOf("older"), + pinnedLibraryBookIds = setOf("older") + ), + recentFilesFromDb = listOf(older, newer), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + assertEquals(listOf("older", "newer"), result.recentFiles.ids()) + assertEquals(listOf("older", "newer"), result.allRecentFiles.ids()) + } + @Test fun `project builds manual tag series and unshelved shelves`() { val favorite = tag("favorite", "Favorite") @@ -479,7 +513,8 @@ class LibraryStateProjectorTest { tags: List = emptyList(), fileSize: Long = 0L, seriesName: String? = null, - seriesIndex: Double? = null + seriesIndex: Double? = null, + customName: String? = null ) = RecentFileItem( bookId = id, uriString = uriString, @@ -494,7 +529,8 @@ class LibraryStateProjectorTest { tags = tags, fileSize = fileSize, seriesName = seriesName, - seriesIndex = seriesIndex + seriesIndex = seriesIndex, + customName = customName ) private fun tag(id: String, name: String) = TagEntity( diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index a059d52..7234ef0 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -9,16 +9,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.credentials.CredentialManager import androidx.work.WorkManager -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingResult import com.aryan.reader.data.* import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.tts.TtsController import com.aryan.reader.tts.TtsPlaybackManager -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.FirebaseFirestore import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -108,28 +104,8 @@ class MainViewModelTest { mockkObject(WorkManager.Companion) val mockWorkManager = mockk(relaxed = true) every { WorkManager.getInstance(any()) } returns mockWorkManager - mockkStatic(FirebaseAuth::class) - every { FirebaseAuth.getInstance() } returns mockk(relaxed = true) - mockkStatic(FirebaseFirestore::class) - every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true) mockkObject(CredentialManager.Companion) every { CredentialManager.create(any()) } returns mockk(relaxed = true) - mockkStatic(BillingClient::class) - val mockBillingClient = mockk(relaxed = true) - val mockBillingBuilder = mockk(relaxed = true) - every { BillingClient.newBuilder(any()) } returns mockBillingBuilder - every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder - every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder - every { mockBillingBuilder.build() } returns mockBillingClient - every { mockBillingClient.isReady } returns false - every { mockBillingClient.startConnection(any()) } answers { - firstArg() - .onBillingSetupFinished( - BillingResult.newBuilder() - .setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - .build() - ) - } mockkConstructor(AuthRepository::class) mockkConstructor(RecentFilesRepository::class) mockkConstructor(BillingClientWrapper::class) @@ -140,6 +116,14 @@ class MainViewModelTest { mockkConstructor(TtsController::class) every { anyConstructed().proUpgradeState } returns billingStateFlow + every { anyConstructed().initializeConnection() } just Runs + every { anyConstructed().refreshPurchasesAsync() } just Runs + every { anyConstructed().clearVerificationState() } just Runs + every { anyConstructed().clearAccountConflict() } just Runs + every { anyConstructed().markAccountConflict() } just Runs + every { anyConstructed().clearError() } just Runs + every { anyConstructed().consumePurchase(any()) } just Runs + every { anyConstructed().launchPurchaseFlow(any(), any(), any()) } just Runs every { anyConstructed().getSignedInUser() } returns null every { anyConstructed().observeAuthState() } returns flowOf(null) every { anyConstructed().init() } just Runs @@ -566,6 +550,36 @@ class MainViewModelTest { verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) } } + @Test + fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val requested = LibraryFilters( + fileTypes = setOf(FileType.PDF, FileType.UNKNOWN), + sourceFolders = setOf("content://sync"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf("favorite") + ) + val expected = requested.copy(fileTypes = setOf(FileType.PDF)) + + viewModel.updateLibraryFilters(requested) + + val state = viewModel.uiState.first { it.libraryFilters == expected } + assertEquals(expected, state.libraryFilters) + assertFalse(FileType.UNKNOWN in state.libraryFilters.fileTypes) + verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF")) } + } + + @Test + fun `saved library file filters drop stale unknown values during restore`() = runTest { + every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN") + + val restored = MainViewModel(mockApplication) + + assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes) + } + @Test fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { @@ -954,6 +968,22 @@ class MainViewModelTest { assertEquals(null, clearedState.bannerMessage) } + @Test + fun `persistent banner is not auto dismissed`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.showBanner("Syncing", isPersistent = true) + viewModel.uiState.first { it.bannerMessage?.message == "Syncing" } + runCurrent() + + advanceTimeBy(3_000L) + runCurrent() + + assertEquals("Syncing", viewModel.uiState.value.bannerMessage?.message) + } + private fun recentFile( id: String, type: FileType = FileType.EPUB, @@ -970,10 +1000,16 @@ class MainViewModelTest { title = title ) - private fun mockUri(uriString: String): Uri { + private fun mockUri( + uriString: String, + path: String? = uriString.substringAfter(":", ""), + lastPathSegment: String? = path?.substringAfterLast('/') + ): Uri { return mockk().also { uri -> every { uri.toString() } returns uriString every { uri.scheme } returns uriString.substringBefore(":", "") + every { uri.path } returns path + every { uri.lastPathSegment } returns lastPathSegment } } diff --git a/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt b/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt deleted file mode 100644 index 62aa50b..0000000 --- a/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.aryan.reader - -import com.aryan.reader.data.RecentFileItem -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class NonReaderScreenModelsTest { - - @Test - fun `home model treats open tabs as non-empty content`() { - val tab = recentFile("tab") - - val model = ReaderScreenState( - isTabsEnabled = true, - openTabs = listOf(tab), - rawLibraryFiles = listOf(tab) - ).toHomeScreenModel() - - assertFalse(model.isEmpty) - assertTrue(model.isLibraryEmpty) - assertEquals(listOf(tab), model.openTabs) - } - - @Test - fun `home model reports empty when there are no recents or open tabs`() { - val archivedBook = recentFile("archived", isRecent = false) - - val model = ReaderScreenState( - recentFiles = emptyList(), - rawLibraryFiles = listOf(archivedBook) - ).toHomeScreenModel() - - assertTrue(model.isEmpty) - assertTrue(model.isLibraryEmpty) - } - - @Test - fun `home model ignores open tabs for empty state when tabs are disabled`() { - val tab = recentFile("tab") - - val model = ReaderScreenState( - isTabsEnabled = false, - openTabs = listOf(tab), - recentFiles = emptyList() - ).toHomeScreenModel() - - assertTrue(model.isEmpty) - assertEquals(listOf(tab), model.openTabs) - } - - @Test - fun `home model exposes contextual selection and device limit state`() { - val selected = recentFile("selected") - val deviceState = DeviceLimitReachedState(isLimitReached = true) - - val model = ReaderScreenState( - recentFiles = listOf(selected), - contextualActionItems = setOf(selected), - deviceLimitState = deviceState - ).toHomeScreenModel() - - assertTrue(model.isContextualModeActive) - assertEquals(setOf(selected), model.selectedItems) - assertEquals(deviceState, model.deviceLimitState) - assertFalse(model.isEmpty) - assertFalse(model.isLibraryEmpty) - } - - @Test - fun `library model exposes contextual and shelf selection state`() { - val folderBook = recentFile("folder", sourceFolderUri = "content://folder") - val shelf = Shelf( - id = "manual", - name = "Manual", - type = ShelfType.MANUAL, - books = listOf(folderBook) - ) - - val model = ReaderScreenState( - contextualActionItems = setOf(folderBook), - contextualActionShelfIds = setOf(shelf.id), - sortOrder = SortOrder.TITLE_ASC, - shelves = listOf(shelf), - rawLibraryFiles = listOf(folderBook), - searchQuery = "folder", - isSearchActive = true - ).toLibraryScreenModel() - - assertTrue(model.isContextualModeActive) - assertTrue(model.isShelfContextualModeActive) - assertTrue(model.containsFolderItemsInSelection) - assertEquals(setOf(folderBook), model.selectedItems) - assertEquals(setOf(shelf.id), model.selectedShelves) - assertEquals(SortOrder.TITLE_ASC, model.sortOrder) - assertEquals("folder", model.searchQuery) - assertTrue(model.isSearchActive) - } - - @Test - fun `library model reports inactive contextual states for normal browsing`() { - val book = recentFile("book") - - val model = ReaderScreenState( - allRecentFiles = listOf(book), - rawLibraryFiles = listOf(book), - sortOrder = SortOrder.RECENT - ).toLibraryScreenModel() - - assertFalse(model.isContextualModeActive) - assertFalse(model.isShelfContextualModeActive) - assertFalse(model.containsFolderItemsInSelection) - assertTrue(model.selectedItems.isEmpty()) - assertTrue(model.selectedShelves.isEmpty()) - assertEquals(listOf(book), model.rawLibraryFiles) - assertEquals(SortOrder.RECENT, model.sortOrder) - } - - @Test - fun `library model distinguishes folder and non-folder selections`() { - val localBook = recentFile("local") - - val model = ReaderScreenState( - contextualActionItems = setOf(localBook), - rawLibraryFiles = listOf(localBook) - ).toLibraryScreenModel() - - assertTrue(model.isContextualModeActive) - assertFalse(model.containsFolderItemsInSelection) - assertEquals(setOf(localBook), model.selectedItems) - } - - private fun recentFile( - id: String, - isRecent: Boolean = true, - sourceFolderUri: String? = null - ) = RecentFileItem( - bookId = id, - uriString = "content://$id", - type = FileType.EPUB, - displayName = "$id.epub", - timestamp = 1L, - isRecent = isRecent, - sourceFolderUri = sourceFolderUri - ) -} diff --git a/app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt b/app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt new file mode 100644 index 0000000..4682e73 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/PurchaseAccountObfuscatorTest.kt @@ -0,0 +1,27 @@ +package com.aryan.reader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseAccountObfuscatorTest { + @Test + fun `obfuscated account id is stable and safe for billing`() { + val accountId = PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123") + + assertTrue(accountId.startsWith("firebase_")) + assertFalse(accountId.contains("=")) + assertEquals(accountId, PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123")) + } + + @Test + fun `purchase token hash matches worker format`() { + val token = "jjjnbecgjekfeigbnagcheee.AO-J1OzOurukZmfLAyu6EdPlEvLIyehyOLYajYbGlEK3knhjN4nGe-BLgjXVrSCfRFocGJ5Wc8VcLazRLZxHTdgiUQ5zULRyoQ" + + assertEquals( + "sha256_KBe2Ev9nqOx9PMypxP3AlwDgm4E-KIa-i5Eenr1QPF8", + PurchaseAccountObfuscator.purchaseTokenHash(token) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt b/app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt new file mode 100644 index 0000000..a4b31b5 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ReaderScreenOrientationTest.kt @@ -0,0 +1,128 @@ +package com.aryan.reader + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ActivityInfo +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderScreenOrientationTest { + + @Test + fun `screen orientation defaults to follow system and falls back for invalid ids`() { + val defaultContext = contextWithPrefs(InMemorySharedPreferences()) + val invalidContext = contextWithPrefs( + InMemorySharedPreferences("reader_screen_orientation_mode" to Int.MIN_VALUE) + ) + + assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(defaultContext)) + assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(invalidContext)) + } + + @Test + fun `screen orientation mode saves loads and maps to activity requested orientation`() { + val context = contextWithPrefs(InMemorySharedPreferences()) + + saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.LANDSCAPE) + assertEquals(ReaderScreenOrientationMode.LANDSCAPE, loadReaderScreenOrientationMode(context)) + + saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.PORTRAIT) + assertEquals(ReaderScreenOrientationMode.PORTRAIT, loadReaderScreenOrientationMode(context)) + + assertEquals( + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, + ReaderScreenOrientationMode.FOLLOW_SYSTEM.toRequestedOrientation() + ) + assertEquals( + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT, + ReaderScreenOrientationMode.PORTRAIT.toRequestedOrientation() + ) + assertEquals( + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE, + ReaderScreenOrientationMode.LANDSCAPE.toRequestedOrientation() + ) + } + + @Test + fun `right to left pagination is separate for pdf and epub`() { + val context = contextWithPrefs(InMemorySharedPreferences()) + + assertEquals(false, loadPdfRightToLeftPagination(context)) + assertEquals(false, loadEpubRightToLeftPagination(context)) + + savePdfRightToLeftPagination(context, true) + assertEquals(true, loadPdfRightToLeftPagination(context)) + assertEquals(false, loadEpubRightToLeftPagination(context)) + + saveEpubRightToLeftPagination(context, true) + assertEquals(true, loadPdfRightToLeftPagination(context)) + assertEquals(true, loadEpubRightToLeftPagination(context)) + + savePdfRightToLeftPagination(context, false) + assertEquals(false, loadPdfRightToLeftPagination(context)) + assertEquals(true, loadEpubRightToLeftPagination(context)) + } + + private fun contextWithPrefs(prefs: SharedPreferences): Context { + val context = mockk() + every { context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) } returns prefs + every { context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) } returns prefs + return context + } + + private class InMemorySharedPreferences(vararg initial: Pair) : SharedPreferences { + private val values = initial.toMap().toMutableMap() + + override fun getAll(): MutableMap = values + override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? { + val value = values[key] as? Set<*> ?: return defValues + return value.filterIsInstance().toMutableSet() + } + override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue + override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue + override fun contains(key: String?): Boolean = values.containsKey(key) + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clearRequested = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value) + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = + applyPut(key, values?.toSet()) + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value) + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value) + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value) + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value) + override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null) + override fun clear(): SharedPreferences.Editor { + clearRequested = true + return this + } + override fun commit(): Boolean { + flush() + return true + } + override fun apply() = flush() + + private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor { + if (key != null) pending[key] = value + return this + } + + private fun flush() { + if (clearRequested) values.clear() + pending.forEach { (key, value) -> + if (value == null) values.remove(key) else values[key] = value + } + } + } + } +} diff --git a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt new file mode 100644 index 0000000..098ad1d --- /dev/null +++ b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt @@ -0,0 +1,155 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookTagCrossRef +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity +import com.aryan.reader.shared.FileType as SharedFileType +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.Shelf as SharedShelf +import com.aryan.reader.shared.ShelfType as SharedShelfType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Test + +class SharedModelMappersTest { + + @Test + fun `book mapper preserves android-only fields when shared projection maps back by id`() { + val tag = TagEntity(id = "tag", name = "Favorite", color = 0xFFAA00AA.toInt(), createdAt = 10L) + val original = recentFile( + id = "book", + type = FileType.PDF, + displayName = "Original.pdf", + customName = "Custom name", + isAvailable = false, + bookmarksJson = """[{"page":2}]""", + sourceFolderUri = "content://folder", + tags = listOf(tag) + ) + + val shared = original.toSharedBookItem() + val mapped = shared.toRecentFileItem( + androidBooksById = mapOf(original.bookId to original), + tagEntitiesById = mapOf(tag.id to tag) + ) + + assertEquals("Custom name", shared.displayName) + assertEquals(original.uriString, mapped.uriString) + assertEquals(original.displayName, mapped.displayName) + assertEquals(original.customName, mapped.customName) + assertEquals(original.bookmarksJson, mapped.bookmarksJson) + assertEquals(original.sourceFolderUri, mapped.sourceFolderUri) + assertFalse(mapped.isAvailable) + assertEquals(listOf(tag), mapped.tags) + } + + @Test + fun `shared projection state maps shelves tabs selections and tags back to android state`() { + val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L) + val book = recentFile("book", tags = listOf(tag)) + val sharedBook = book.toSharedBookItem() + val sharedShelf = SharedShelf( + id = "manual", + name = "Manual", + type = SharedShelfType.MANUAL, + books = listOf(sharedBook), + directBooks = listOf(sharedBook) + ) + val projected = SharedReaderScreenState( + recentBooks = listOf(sharedBook), + libraryBooks = listOf(sharedBook), + rawLibraryBooks = listOf(sharedBook), + selectedBookIds = setOf("book", "missing"), + selectedShelfIds = setOf("manual"), + shelves = listOf(sharedShelf), + openTabs = listOf(sharedBook), + openTabIds = listOf("book"), + activeTabBookId = "book", + booksAvailableForAdding = listOf(sharedBook), + allTags = listOf(tag.toSharedTag()) + ) + + val android = projected.toAndroidReaderScreenState( + base = ReaderScreenState(contextualActionItems = setOf(recentFile("missing"))), + androidBooksById = mapOf(book.bookId to book), + tagEntitiesById = mapOf(tag.id to tag) + ) + + assertEquals(listOf("book"), android.recentFiles.ids()) + assertEquals(listOf("book"), android.allRecentFiles.ids()) + assertEquals(listOf("book"), android.rawLibraryFiles.ids()) + assertEquals(setOf("book"), android.contextualActionItems.mapTo(mutableSetOf()) { it.bookId }) + assertEquals(setOf("manual"), android.contextualActionShelfIds) + assertEquals(listOf("manual"), android.shelves.map { it.id }) + assertEquals(listOf("book"), android.shelves.single().books.ids()) + assertEquals(listOf("book"), android.openTabs.ids()) + assertEquals(listOf("book"), android.openTabIds) + assertEquals("book", android.activeTabBookId) + assertEquals(listOf("book"), android.booksAvailableForAdding.ids()) + assertEquals(listOf(tag), android.allTags) + } + + @Test + fun `enum filter and folder mappers round trip between android and shared`() { + val filters = LibraryFilters( + fileTypes = setOf(FileType.PDF, FileType.EPUB), + sourceFolders = setOf("IN_APP_STORAGE", "content://folder"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf("tag") + ) + val folder = SyncedFolder( + uriString = "content://folder", + name = "Folder", + lastScanTime = 42L, + allowedFileTypes = setOf(FileType.PDF, FileType.CBZ) + ) + + assertEquals(FileType.PDF, SharedFileType.PDF.toAndroidFileType()) + assertEquals(SharedFileType.CBZ, FileType.CBZ.toSharedFileType()) + assertSame(FileType.UNKNOWN, SharedFileType.UNKNOWN.toAndroidFileType()) + assertSame(filters, filters.toSharedLibraryFilters()) + assertSame(folder, folder.toSharedSyncedFolder()) + assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters()) + assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder()) + assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES) + assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES) + } + + @Test + fun `tag resolver attaches database tags before shared projection`() { + val tag = TagEntity(id = "tag", name = "Reference", createdAt = 1L) + val files = listOf(recentFile("book")) + + val tagged = files.withResolvedTags( + dbTags = listOf(tag), + tagRefs = listOf(BookTagCrossRef(bookId = "book", tagId = "tag")) + ) + + assertEquals(listOf(tag), tagged.single().tags) + } + + private fun recentFile( + id: String, + type: FileType = FileType.EPUB, + displayName: String = "$id.${type.name.lowercase()}", + customName: String? = null, + isAvailable: Boolean = true, + bookmarksJson: String? = null, + sourceFolderUri: String? = null, + tags: List = emptyList() + ) = RecentFileItem( + bookId = id, + uriString = "content://$id", + type = type, + displayName = displayName, + timestamp = 1L, + isAvailable = isAvailable, + bookmarksJson = bookmarksJson, + sourceFolderUri = sourceFolderUri, + customName = customName, + tags = tags + ) + + private fun List.ids() = map { it.bookId } +} diff --git a/app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt b/app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt new file mode 100644 index 0000000..ee19115 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/FileTypeConverterTest.kt @@ -0,0 +1,21 @@ +package com.aryan.reader.data + +import com.aryan.reader.FileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FileTypeConverterTest { + + @Test + fun `room converter stores enum names and preserves unknown fallback`() { + val converter = FileTypeConverter() + + assertEquals("PDF", converter.fromFileType(FileType.PDF)) + assertEquals(FileType.PDF, converter.toFileType("PDF")) + assertEquals("UNKNOWN", converter.fromFileType(FileType.UNKNOWN)) + assertEquals(FileType.UNKNOWN, converter.toFileType("UNKNOWN")) + assertNull(converter.fromFileType(null)) + assertNull(converter.toFileType(null)) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt b/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt index bd5009a..5392302 100644 --- a/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt +++ b/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt @@ -31,7 +31,15 @@ class FolderBookMetadataTest { val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString()) - assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded) + assertEquals( + metadata.copy( + title = null, + author = null, + lastPage = null, + locatorCharOffset = null + ), + decoded + ) } @Test @@ -78,8 +86,8 @@ class FolderBookMetadataTest { assertEquals("book-2", item.bookId) assertEquals(FileType.EPUB, item.type) - assertEquals("Remote Title", item.title) - assertEquals("Author", item.author) + assertEquals("Remote", item.title) + assertNull(item.author) assertEquals(12, item.lastPage) assertEquals(7, item.locatorBlockIndex) assertEquals(8, item.locatorCharOffset) @@ -87,4 +95,34 @@ class FolderBookMetadataTest { assertEquals("Shelf Name", item.customName) assertEquals("highlights", item.highlightsJson) } + + @Test + fun `toRecentFileItem preserves explicit unknown file type`() { + val metadata = FolderBookMetadata( + bookId = "book-3", + title = null, + author = null, + displayName = "Remote.bin", + type = "UNKNOWN", + lastChapterIndex = null, + lastPage = null, + lastPositionCfi = null, + progressPercentage = 0f, + isRecent = false, + lastModifiedTimestamp = 500L, + bookmarksJson = null, + locatorBlockIndex = null, + locatorCharOffset = null, + customName = null, + highlightsJson = null + ) + + val item = metadata.toRecentFileItem( + uriString = "content://book", + coverPath = null, + sourceFolderUri = "content://folder" + ) + + assertEquals(FileType.UNKNOWN, item.type) + } } diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt new file mode 100644 index 0000000..5ab3abe --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/RecentFileDaoMetadataExtractionTest.kt @@ -0,0 +1,244 @@ +package com.aryan.reader.data + +import androidx.room.Room +import com.aryan.reader.FileType +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RecentFileDaoMetadataExtractionTest { + + private lateinit var db: AppDatabase + private lateinit var dao: RecentFileDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + RuntimeEnvironment.getApplication(), + AppDatabase::class.java + ).allowMainThreadQueries().build() + dao = db.recentFileDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `cover-only ebook metadata candidate is marked attempted after no cover is found`() = runTest { + dao.insertOrUpdateFile( + recentFileEntity( + folderTextMetadataParsed = true, + folderCoverMetadataParsed = false + ) + ) + + assertEquals(1, dao.countFolderBooksNeedingTextMetadata("content://folder")) + + dao.updateExtractedMetadata( + bookId = "book-1", + coverImagePath = null, + title = null, + author = null, + seriesName = null, + seriesIndex = null, + description = null, + fileSize = 0L, + fileContentModifiedTimestamp = 0L, + textMetadataParsed = false, + coverMetadataParsed = true + ) + + val saved = dao.getFileByBookId("book-1")!! + assertFalse(saved.coverImagePath?.isNotBlank() == true) + assertTrue(saved.folderCoverMetadataParsed) + assertEquals(0, dao.countFolderBooksNeedingTextMetadata("content://folder")) + } + + @Test + fun `metadata candidate query respects batch limit`() = runTest { + dao.insertOrUpdateFile(recentFileEntity(bookId = "book-1", timestamp = 1_000L)) + dao.insertOrUpdateFile(recentFileEntity(bookId = "book-2", timestamp = 2_000L)) + + val pending = dao.getFolderBooksNeedingTextMetadata("content://folder", limit = 1) + + assertEquals(1, pending.size) + assertEquals("book-2", pending.single().bookId) + } + + @Test + fun `metadata extraction does not replace user edited metadata`() = runTest { + dao.insertOrUpdateFile( + recentFileEntity().copy( + title = "Edited title", + author = "Edited author", + originalTitle = "Original title", + originalAuthor = "Original author" + ) + ) + + dao.updateExtractedMetadata( + bookId = "book-1", + coverImagePath = null, + title = "Extracted title", + author = "Extracted author", + seriesName = null, + seriesIndex = null, + description = null, + fileSize = 0L, + fileContentModifiedTimestamp = 0L, + textMetadataParsed = true, + coverMetadataParsed = false + ) + + val saved = dao.getFileByBookId("book-1")!! + assertEquals("Edited title", saved.title) + assertEquals("Edited author", saved.author) + assertEquals("Original title", saved.originalTitle) + assertEquals("Original author", saved.originalAuthor) + } + + @Test + fun `metadata extraction promotes extracted values before user edits`() = runTest { + dao.insertOrUpdateFile( + recentFileEntity().copy( + title = "book-1.epub", + author = null, + originalTitle = "book-1.epub", + originalAuthor = null + ) + ) + + dao.updateExtractedMetadata( + bookId = "book-1", + coverImagePath = null, + title = "Extracted title", + author = "Extracted author", + seriesName = null, + seriesIndex = null, + description = null, + fileSize = 0L, + fileContentModifiedTimestamp = 0L, + textMetadataParsed = true, + coverMetadataParsed = false + ) + + val saved = dao.getFileByBookId("book-1")!! + assertEquals("Extracted title", saved.title) + assertEquals("Extracted author", saved.author) + assertEquals("Extracted title", saved.originalTitle) + assertEquals("Extracted author", saved.originalAuthor) + } + + @Test + fun `restore original metadata restores snapshot and clears display override`() = runTest { + dao.insertOrUpdateFile( + recentFileEntity().copy( + title = "Edited title", + author = "Edited author", + seriesName = "Edited series", + seriesIndex = 2.0, + description = "Edited summary", + customName = "Edited display", + originalTitle = "Original title", + originalAuthor = "Original author", + originalSeriesName = "Original series", + originalSeriesIndex = 1.0, + originalDescription = "Original summary" + ) + ) + + dao.restoreOriginalMetadata("book-1", fileSize = 0L, fileContentModifiedTimestamp = 0L, timestamp = 9_000L) + + val saved = dao.getFileByBookId("book-1")!! + assertEquals("Original title", saved.title) + assertEquals("Original author", saved.author) + assertEquals("Original series", saved.seriesName) + assertEquals(1.0, saved.seriesIndex) + assertEquals("Original summary", saved.description) + assertNull(saved.customName) + assertEquals(9_000L, saved.lastModifiedTimestamp) + } + + @Test + fun `manual metadata update seeds missing original snapshot from previous values`() = runTest { + dao.insertOrUpdateFile( + recentFileEntity().copy( + title = "Existing title", + author = "Existing author", + customName = "Display override", + originalTitle = null, + originalAuthor = null + ) + ) + + dao.updateUserEditableMetadata( + bookId = "book-1", + title = "Edited title", + author = "Edited author", + seriesName = null, + seriesIndex = null, + description = null, + fileSize = 0L, + fileContentModifiedTimestamp = 0L, + timestamp = 5_000L + ) + + val saved = dao.getFileByBookId("book-1")!! + assertEquals("Edited title", saved.title) + assertEquals("Edited author", saved.author) + assertEquals("Existing title", saved.originalTitle) + assertEquals("Existing author", saved.originalAuthor) + assertNull(saved.customName) + } + + private fun recentFileEntity( + bookId: String = "book-1", + timestamp: Long = 1_000L, + folderTextMetadataParsed: Boolean = false, + folderCoverMetadataParsed: Boolean = false + ): RecentFileEntity { + return RecentFileEntity( + bookId = bookId, + uriString = "content://books/$bookId", + type = FileType.EPUB, + displayName = "$bookId.epub", + timestamp = timestamp, + coverImagePath = null, + title = "One", + author = "Author", + lastChapterIndex = null, + lastPage = null, + lastPositionCfi = null, + progressPercentage = null, + isRecent = false, + isAvailable = true, + lastModifiedTimestamp = timestamp, + isDeleted = false, + locatorBlockIndex = null, + locatorCharOffset = null, + bookmarks = null, + sourceFolderUri = "content://folder", + isReflowPreferred = false, + customName = null, + highlights = null, + fileSize = 123L, + fileContentModifiedTimestamp = 1_234L, + seriesName = null, + seriesIndex = null, + description = null, + folderTextMetadataParsed = folderTextMetadataParsed, + folderCoverMetadataParsed = folderCoverMetadataParsed + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt index 8a72724..831c304 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt @@ -17,6 +17,7 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) + assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) } @Test @@ -30,6 +31,59 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) + assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) + } + + @Test + fun `cloud metadata mapping preserves non epub display rename as custom name`() { + val item = recentFileItem().copy( + type = FileType.PDF, + customName = "Reader Display Name" + ) + + val roundTripped = item.toBookMetadata().toRecentFileItem() + + assertEquals("Reader Display Name", roundTripped.customName) + assertEquals("One.epub", roundTripped.displayName) + } + + @Test + fun `recent file entity mapping preserves original metadata snapshot`() { + val item = recentFileItem().copy( + title = "Edited One", + author = "Edited Author", + seriesName = "Edited Series", + seriesIndex = 2.0, + description = "Edited summary", + originalTitle = "Original One", + originalAuthor = "Original Author", + originalSeriesName = "Original Series", + originalSeriesIndex = 1.0, + originalDescription = "Original summary" + ) + + val roundTripped = item.toRecentFileEntity().toRecentFileItem() + + assertEquals("Original One", roundTripped.originalTitle) + assertEquals("Original Author", roundTripped.originalAuthor) + assertEquals("Original Series", roundTripped.originalSeriesName) + assertEquals(1.0, roundTripped.originalSeriesIndex) + assertEquals("Original summary", roundTripped.originalDescription) + } + + @Test + fun `recent file entity mapping seeds original metadata when first stored`() { + val entity = recentFileItem().copy( + seriesName = "Series", + seriesIndex = 1.0, + description = "Summary" + ).toRecentFileEntity() + + assertEquals("One", entity.originalTitle) + assertEquals("Author", entity.originalAuthor) + assertEquals("Series", entity.originalSeriesName) + assertEquals(1.0, entity.originalSeriesIndex) + assertEquals("Summary", entity.originalDescription) } private fun recentFileItem(): RecentFileItem { @@ -47,6 +101,7 @@ class RecentFileItemReadingPositionMappingTest { locatorCharOffset = 88, progressPercentage = 61.5f, lastModifiedTimestamp = 2_000L, + fileContentModifiedTimestamp = 3_000L, bookmarksJson = """[{"cfi":"/4/2"}]""", highlightsJson = """[{"cfi":"/4/2/6:88"}]""" ) diff --git a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt index e210980..2057e0e 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt @@ -14,7 +14,9 @@ import io.mockk.unmockkObject import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import java.io.File @@ -113,14 +115,82 @@ class RecentFilesRepositoryReadingPositionMergeTest { assertEquals(82f, inserted.captured.progressPercentage) } - private fun existingEntity(): RecentFileEntity { + @Test + fun `addRecentFile keeps edited embedded epub metadata when cached parser returns original metadata`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy( + author = "Edited Author", + originalAuthor = "Author", + fileContentModifiedTimestamp = 5_000L + ) + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 2_000L, + title = "Old", + author = "Author", + fileContentModifiedTimestamp = 5_000L, + isRecent = true + ) + ) + + assertEquals("Edited Author", inserted.captured.author) + assertEquals("Author", inserted.captured.originalAuthor) + } + + @Test + fun `addRecentFile clears extracted metadata when folder file size changes`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity( + fileSize = 123L, + folderTextMetadataParsed = true, + folderCoverMetadataParsed = true, + coverImagePath = "/covers/old.png" + ) + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 2_000L, + sourceFolderUri = "content://folder", + fileSize = 456L, + isRecent = true + ) + ) + + assertEquals(456L, inserted.captured.fileSize) + assertNull(inserted.captured.coverImagePath) + assertEquals("New", inserted.captured.title) + assertNull(inserted.captured.author) + assertNull(inserted.captured.seriesName) + assertNull(inserted.captured.description) + assertNull(inserted.captured.originalTitle) + assertFalse(inserted.captured.folderTextMetadataParsed) + assertFalse(inserted.captured.folderCoverMetadataParsed) + } + + private fun existingEntity( + fileSize: Long = 123L, + folderTextMetadataParsed: Boolean = true, + folderCoverMetadataParsed: Boolean = false, + coverImagePath: String? = "/covers/old.png" + ): RecentFileEntity { return RecentFileEntity( bookId = "book-1", uriString = "content://old", type = FileType.EPUB, displayName = "Old.epub", timestamp = 1_000L, - coverImagePath = "/covers/old.png", + coverImagePath = coverImagePath, title = "Old", author = "Author", lastChapterIndex = 6, @@ -138,11 +208,12 @@ class RecentFilesRepositoryReadingPositionMergeTest { isReflowPreferred = false, customName = "Custom", highlights = "highlights", - fileSize = 123L, + fileSize = fileSize, seriesName = "Series", seriesIndex = 1.0, description = "Description", - folderTextMetadataParsed = true + folderTextMetadataParsed = folderTextMetadataParsed, + folderCoverMetadataParsed = folderCoverMetadataParsed ) } } diff --git a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt index e643576..1b96604 100644 --- a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt +++ b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt @@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.util.zip.ZipEntry +import java.util.zip.ZipFile import java.util.zip.ZipOutputStream class EpubParserUnitTest { @@ -103,6 +104,43 @@ class EpubParserUnitTest { assertTrue(extractionDir.list().isNullOrEmpty()) } + @Test + fun `metadata only extraction streams images to disk without retaining image bytes`() { + val cacheDir = temp.newFolder("cache-metadata-stream") + val extractionDir = temp.newFolder("extract-metadata-stream") + val parser = EpubParser(contextWithCache(cacheDir)) + val imageBytes = ByteArray(2 * 1024 * 1024) { 7 } + val zipFileOnDisk = File(temp.root, "metadata-stream.epub") + zipFileOnDisk.writeBytes( + zipBinaryBytes( + "META-INF/container.xml" to """ + + """.trimIndent().toByteArray(Charsets.UTF_8), + "OEBPS/content.opf" to """ + + + + + + + + """.trimIndent().toByteArray(Charsets.UTF_8), + "OEBPS/images/cover.jpg" to imageBytes + ) + ) + + val files = parser.extractEpubContents( + zipFile = ZipFile(zipFileOnDisk), + extractionDir = extractionDir, + parseContent = false, + extractImagesForMetadata = true + ) + + assertTrue(files["META-INF/container.xml"]!!.data.isNotEmpty()) + assertEquals(0, files["OEBPS/images/cover.jpg"]!!.data.size) + assertEquals(imageBytes.size.toLong(), File(extractionDir, "OEBPS/images/cover.jpg").length()) + } + @Test fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest { val cacheDir = temp.newFolder("cache-warm-open") @@ -129,6 +167,35 @@ class EpubParserUnitTest { assertTrue(File(activeDir, "sentinel.txt").isFile) } + @Test + fun `createEpubBook invalidates active extraction cache when source fingerprint changes`() = runTest { + val cacheDir = temp.newFolder("cache-source-change") + val context = contextWithCache(cacheDir) + val parser = EpubParser(context) + + val first = parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes()), + bookId = "changed-book", + shouldUseToc = true, + originalBookNameHint = "changed.epub", + sourceFingerprint = "100:1000" + ) + val activeDir = ImportedFileCache.activeBookDir(context, "changed-book") + File(activeDir, "sentinel.txt").writeText("old extraction") + + val second = parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes(author = "Edited Writer")), + bookId = "changed-book", + shouldUseToc = true, + originalBookNameHint = "changed.epub", + sourceFingerprint = "120:2000" + ) + + assertEquals("Jane Writer", first.author) + assertEquals("Edited Writer", second.author) + assertFalse(File(activeDir, "sentinel.txt").isFile) + } + @Test fun `metadata only parse does not clear active extracted content`() = runTest { val cacheDir = temp.newFolder("cache-metadata-preserve") @@ -325,7 +392,7 @@ class EpubParserUnitTest { return context } - private fun sampleEpubBytes(): ByteArray = zipBytes( + private fun sampleEpubBytes(author: String = "Jane Writer"): ByteArray = zipBytes( "META-INF/container.xml" to """ @@ -335,7 +402,7 @@ class EpubParserUnitTest { Sample/Book - Jane Writer + $author en Long description @@ -415,11 +482,15 @@ class EpubParserUnitTest { ) private fun zipBytes(vararg entries: Pair): ByteArray { + return zipBinaryBytes(*entries.map { it.first to it.second.toByteArray(Charsets.UTF_8) }.toTypedArray()) + } + + private fun zipBinaryBytes(vararg entries: Pair): ByteArray { val out = ByteArrayOutputStream() ZipOutputStream(out).use { zip -> entries.forEach { (name, content) -> zip.putNextEntry(ZipEntry(name)) - zip.write(content.toByteArray(Charsets.UTF_8)) + zip.write(content) zip.closeEntry() } } diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt index e390f46..cacfccf 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -196,5 +196,6 @@ class EpubReaderBridgeAndControlsTest { assertTrue(ReaderTool.entries.any { it.category == "Top Bar" }) assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" }) assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" }) + assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category) } } diff --git a/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt b/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt index accd2ed..6d665f5 100644 --- a/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt +++ b/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt @@ -188,6 +188,10 @@ class OpdsParserTest { val acquisitions = listOf( OpdsAcquisition("txt", "text/plain"), OpdsAcquisition("pdf", "application/pdf"), + OpdsAcquisition( + "pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), OpdsAcquisition("epub", "application/epub+zip"), OpdsAcquisition("unknown", "application/octet-stream") ) @@ -200,9 +204,10 @@ class OpdsParserTest { navigationUrl = null ) - assertEquals("EPUB", acquisitions[2].formatName) + assertEquals("EPUB", acquisitions[3].formatName) + assertEquals("PPTX", acquisitions[2].formatName) assertEquals("TXT", acquisitions[0].formatName) - assertEquals("OCTET-STREAM", acquisitions[3].formatName) - assertEquals(acquisitions[2], entry.bestAcquisition) + assertEquals("OCTET-STREAM", acquisitions[4].formatName) + assertEquals(acquisitions[3], entry.bestAcquisition) } } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt index 7418920..3e5290c 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt @@ -1,6 +1,8 @@ package com.aryan.reader.paginatedreader import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -20,6 +22,14 @@ class CfiUtilsTest { assertEquals(0, CfiUtils.getOffset("/4/2/6:bad")) } + @Test + fun `getOffsetOrNull only returns explicit numeric offsets`() { + assertEquals(13, CfiUtils.getOffsetOrNull("/4/2/6:13")) + assertEquals(0, CfiUtils.getOffsetOrNull("/4/2/6:0")) + assertNull(CfiUtils.getOffsetOrNull("/4/2/6")) + assertNull(CfiUtils.getOffsetOrNull("/4/2/6:bad")) + } + @Test fun `compare sorts numeric cfi paths before character offsets`() { assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0) @@ -27,4 +37,12 @@ class CfiUtilsTest { assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0) assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6")) } + + @Test + fun `isPathStrictlyBetween requires a bounded numeric cfi path`() { + assertTrue(CfiUtils.isPathStrictlyBetween("/4/4", "/4/2:1", "/4/6:1")) + assertFalse(CfiUtils.isPathStrictlyBetween("/4/2", "/4/2:1", "/4/6:1")) + assertFalse(CfiUtils.isPathStrictlyBetween("/4/8", "/4/2:1", "/4/6:1")) + assertFalse(CfiUtils.isPathStrictlyBetween("/4/nav", "/4/2:1", "/4/6:1")) + } } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt index 703abd9..fc01dcc 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt @@ -1,9 +1,12 @@ package com.aryan.reader.paginatedreader import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp import org.junit.Assert.assertEquals @@ -123,6 +126,47 @@ class ContentStylerTest { assertEquals("li1", first.elementId) assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item) assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item) + assertTrue(first.content.spanStyles.any { range -> + range.start <= 0 && + range.end >= 5 && + range.item.color.isSpecified && + range.item.color != Color.Red && + range.item.background.isSpecified && + range.item.textDecoration?.contains(TextDecoration.Underline) == true + }) + } + + @Test + fun `runtime theme reapplies visible link style for cached paginated text`() { + val linkText = "Cached link" + val text = buildAnnotatedString { + append(linkText) + addStringAnnotation("URL", "https://example.org", 0, linkText.length) + } + val page = Page( + content = listOf( + ParagraphBlock( + content = text, + blockIndex = 1 + ) + ) + ) + + val themed = page.applyReaderThemeForDisplay( + isDarkTheme = true, + themeBackgroundColor = Color(0xFF121212), + themeTextColor = Color(0xFFE0E0E0) + ) + val paragraph = themed.content.single() as ParagraphBlock + + assertTrue(paragraph.content.spanStyles.any { range -> + range.start == 0 && + range.end == linkText.length && + range.item.color.isSpecified && + range.item.color != Color(0xFFE0E0E0) && + range.item.background.isSpecified && + range.item.textDecoration?.contains(TextDecoration.Underline) == true + }) } @Test diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt new file mode 100644 index 0000000..c76e782 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt @@ -0,0 +1,83 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.text.AnnotatedString +import com.aryan.reader.epubreader.HighlightColor +import com.aryan.reader.epubreader.UserHighlight +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PaginatedHighlightMappingTest { + + @Test + fun `single cfi highlight does not leak onto later matching block`() { + val block = paragraph( + text = "repeat", + cfi = "/4/4", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0", + text = "repeat" + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `multipart highlight can fill strict intermediate block`() { + val block = paragraph( + text = "middle", + cfi = "/4/4", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0|/4/6:10", + text = "start middle end" + ) + + assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `same path split block outside stored offsets is ignored`() { + val block = paragraph( + text = "repeat", + cfi = "/4/2", + startOffset = 20 + ) + val highlight = highlight( + cfi = "/4/2:0|/4/2:6", + text = "repeat" + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + private fun paragraph( + text: String, + cfi: String, + startOffset: Int + ): ParagraphBlock { + return ParagraphBlock( + content = AnnotatedString(text), + cfi = cfi, + startCharOffsetInSource = startOffset, + endCharOffsetInSource = startOffset + text.length, + blockIndex = startOffset + ) + } + + private fun highlight( + cfi: String, + text: String + ): UserHighlight { + return UserHighlight( + id = "highlight", + cfi = cfi, + text = text, + color = HighlightColor.YELLOW, + chapterIndex = 0 + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt new file mode 100644 index 0000000..a5a35e5 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/StablePaginatedNavigationTest.kt @@ -0,0 +1,97 @@ +package com.aryan.reader.paginatedreader + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StablePaginatedNavigationTest { + + @Test + fun `chapter zero needs no prefix stabilization`() = runTest { + val requested = mutableListOf() + + val startPage = resolveStableChapterStartPage( + chapterIndex = 0, + chapterCount = 4, + pageCountsAreAccurate = false, + chapterStartPage = { chapterStarts[it] }, + isChapterFinalized = { false }, + ensureChapterPaginated = { + requested += it + true + } + ) + + assertEquals(0, startPage) + assertTrue(requested.isEmpty()) + } + + @Test + fun `target skips finalized prefix chapters`() = runTest { + val requested = mutableListOf() + + val startPage = resolveStableChapterStartPage( + chapterIndex = 3, + chapterCount = 5, + pageCountsAreAccurate = false, + chapterStartPage = { chapterStarts[it] }, + isChapterFinalized = { it == 0 || it == 2 }, + ensureChapterPaginated = { + requested += it + true + } + ) + + assertEquals(45, startPage) + assertEquals(listOf(1), requested) + } + + @Test + fun `accurate cached page counts skip prefix pagination`() = runTest { + val requested = mutableListOf() + + val startPage = resolveStableChapterStartPage( + chapterIndex = 4, + chapterCount = 5, + pageCountsAreAccurate = true, + chapterStartPage = { chapterStarts[it] }, + isChapterFinalized = { false }, + ensureChapterPaginated = { + requested += it + true + } + ) + + assertEquals(60, startPage) + assertTrue(requested.isEmpty()) + } + + @Test + fun `missing prefix chapters are requested in order`() = runTest { + val requested = mutableListOf() + + val startPage = resolveStableChapterStartPage( + chapterIndex = 4, + chapterCount = 5, + pageCountsAreAccurate = false, + chapterStartPage = { chapterStarts[it] }, + isChapterFinalized = { false }, + ensureChapterPaginated = { + requested += it + true + } + ) + + assertEquals(60, startPage) + assertEquals(listOf(0, 1, 2, 3), requested) + } + + private val chapterStarts = mapOf( + 0 to 0, + 1 to 10, + 2 to 25, + 3 to 45, + 4 to 60 + ) +} diff --git a/app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt b/app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt new file mode 100644 index 0000000..ba97fbf --- /dev/null +++ b/app/src/test/java/com/aryan/reader/pdf/MagnifierGeometryTest.kt @@ -0,0 +1,99 @@ +package com.aryan.reader.pdf + +import android.graphics.Rect +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MagnifierGeometryTest { + + @Test + fun `base bitmap sample maps displayed page coordinates into rendered source pixels`() { + val source = MagnifierContentSource( + sourceWidth = 300, + sourceHeight = 600, + contentLeft = 0f, + contentTop = 0f, + contentWidth = 200f, + contentHeight = 400f + ) + + val sample = requireNotNull( + calculateMagnifierSampleGeometry( + centerContentX = 50f, + centerContentY = 200f, + contentSource = source, + magnifierWidthPx = 120f, + magnifierHeightPx = 60f, + zoomFactor = 2f + ) + ) + + assertEquals(30, sample.srcLeft) + assertEquals(278, sample.srcTop) + assertEquals(90, sample.srcWidth) + assertEquals(45, sample.srcHeight) + } + + @Test + fun `selection rect uses same rendered source transform as magnifier crop`() { + val source = MagnifierContentSource( + sourceWidth = 300, + sourceHeight = 600, + contentLeft = 0f, + contentTop = 0f, + contentWidth = 200f, + contentHeight = 400f + ) + val sample = requireNotNull( + calculateMagnifierSampleGeometry( + centerContentX = 50f, + centerContentY = 200f, + contentSource = source, + magnifierWidthPx = 120f, + magnifierHeightPx = 60f, + zoomFactor = 2f + ) + ) + + val mapped = mapContentRectToMagnifier( + contentRect = Rect(40, 190, 70, 210), + contentSource = source, + sample = sample + ) + + assertEquals(40f, mapped.left, 0.01f) + assertEquals(100f, mapped.right, 0.01f) + assertEquals(29.33f, mapped.centerY(), 0.05f) + } + + @Test + fun `tile sample uses tile local source scale`() { + val source = MagnifierContentSource( + sourceWidth = 512, + sourceHeight = 512, + contentLeft = 100f, + contentTop = 200f, + contentWidth = 256f, + contentHeight = 256f + ) + + val sample = requireNotNull( + calculateMagnifierSampleGeometry( + centerContentX = 228f, + centerContentY = 328f, + contentSource = source, + magnifierWidthPx = 120f, + magnifierHeightPx = 60f, + zoomFactor = 2f + ) + ) + + assertEquals(196, sample.srcLeft) + assertEquals(226, sample.srcTop) + assertEquals(120, sample.srcWidth) + assertEquals(60, sample.srcHeight) + } +} diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt new file mode 100644 index 0000000..6524c5a --- /dev/null +++ b/app/src/test/java/com/aryan/reader/pdf/PdfBitmapPoolTest.kt @@ -0,0 +1,39 @@ +package com.aryan.reader.pdf + +import androidx.core.graphics.createBitmap +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfBitmapPoolTest { + + @After + fun tearDown() { + PdfBitmapPool.clear() + } + + @Test + fun `recycle leaves overflow bitmaps valid for render thread handoff`() { + PdfBitmapPool.clear() + val bitmaps = List(6) { createBitmap(8, 8) } + + bitmaps.forEach(PdfBitmapPool::recycle) + + bitmaps.forEach { bitmap -> + assertFalse(bitmap.isRecycled) + } + } + + @Test + fun `clear drops pooled bitmaps without invalidating external references`() { + val bitmap = createBitmap(8, 8) + + PdfBitmapPool.recycle(bitmap) + PdfBitmapPool.clear() + + assertFalse(bitmap.isRecycled) + } +} diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt index 1979add..c65b4b7 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt @@ -1,7 +1,12 @@ package com.aryan.reader.pdf +import android.content.Context import android.graphics.RectF import android.graphics.Rect +import androidx.compose.ui.graphics.Color +import com.aryan.reader.pdf.data.PdfAnnotation +import com.aryan.reader.pdf.data.PdfAnnotationRepository +import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.ocr.OcrBlock import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrLine @@ -13,6 +18,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class PdfReaderCoreLogicTest { @@ -49,6 +55,40 @@ class PdfReaderCoreLogicTest { ) } + @Test + fun `locked orientation reset camera returns base fit zoom and target page pan`() { + val camera = calculateLockedOrientationResetCamera( + pageTopY = 1_000f, + totalDocHeight = 3_000f, + screenWidth = 800f, + screenHeight = 1_200f, + headerHeightPx = 40f, + footerHeightPx = 60f, + fitZoom = 1f + ) + + assertEquals(1f, camera.zoom, 0.0001f) + assertEquals(0f, camera.panX, 0.0001f) + assertEquals(-960f, camera.panY, 0.0001f) + } + + @Test + fun `locked orientation reset camera centers narrow fit zoom and clamps short documents`() { + val camera = calculateLockedOrientationResetCamera( + pageTopY = 120f, + totalDocHeight = 500f, + screenWidth = 1_000f, + screenHeight = 900f, + headerHeightPx = 40f, + footerHeightPx = 60f, + fitZoom = 0.5f + ) + + assertEquals(0.5f, camera.zoom, 0.0001f) + assertEquals(250f, camera.panX, 0.0001f) + assertEquals(40f, camera.panY, 0.0001f) + } + @Test fun `getSuggestedFilename sanitizes truncates and marks annotated copies`() { val filename = getSuggestedFilename( @@ -67,6 +107,66 @@ class PdfReaderCoreLogicTest { assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf"))) } + @Test + fun `pdfRenderPageId separates same page across documents`() { + val firstDocumentPage = pdfRenderPageId("book-a", 0, VirtualPage.PdfPage(0)) + val secondDocumentPage = pdfRenderPageId("book-b", 0, VirtualPage.PdfPage(0)) + + assertEquals("book-a:PDF_0", firstDocumentPage) + assertTrue(firstDocumentPage != secondDocumentPage) + } + + @Test + fun `pdfRenderPageId preserves virtual page source identity`() { + assertEquals("book:PDF_12", pdfRenderPageId("book", 3, VirtualPage.PdfPage(12))) + assertEquals( + "book:BLANK_blank-1", + pdfRenderPageId("book", 3, VirtualPage.BlankPage("blank-1", 595, 842)) + ) + } + + @Test + fun `bubble prefetch only includes current page and nearby pages`() { + assertEquals(listOf(10, 11, 9), buildPdfBubblePrefetchOrder(currentPage = 10, totalPages = 100)) + } + + @Test + fun `bubble prefetch clamps current page and respects edges`() { + assertEquals(listOf(0, 1), buildPdfBubblePrefetchOrder(currentPage = -4, totalPages = 5)) + assertEquals(listOf(4, 3), buildPdfBubblePrefetchOrder(currentPage = 99, totalPages = 5)) + assertEquals(emptyList(), buildPdfBubblePrefetchOrder(currentPage = 0, totalPages = 0)) + } + + @Test + fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() { + assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true)) + assertEquals(false, canUsePdfSidecarsForBook("book-a", "book-b", areSidecarsLoaded = true)) + assertEquals(false, canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = false)) + assertEquals(false, canUsePdfSidecarsForBook(null, "book-a", areSidecarsLoaded = true)) + } + + @Test + fun `saveAnnotations deletes stored annotations when saving empty map`() = runTest { + val context: Context = RuntimeEnvironment.getApplication() + val repository = PdfAnnotationRepository(context) + val bookId = "empty-annotation-save-${System.nanoTime()}" + val annotation = PdfAnnotation( + type = AnnotationType.INK, + inkType = InkType.PEN, + pageIndex = 0, + points = listOf(PdfPoint(0.1f, 0.2f)), + color = Color.Black, + strokeWidth = 0.01f + ) + + repository.saveAnnotations(bookId, mapOf(0 to listOf(annotation))) + assertEquals(1, repository.loadAnnotations(bookId)[0]?.size) + + repository.saveAnnotations(bookId, emptyMap()) + + assertEquals(emptyMap>(), repository.loadAnnotations(bookId)) + } + @Test fun `preprocessTextForTts returns empty processed text for blank input`() { val processed = preprocessTextForTts(" \n\t ") diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt index b42cddf..8d9df80 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt @@ -31,7 +31,10 @@ class PdfReaderPreferencesTest { assertEquals(PdfReaderTool.entries.size, order.size) assertEquals(PdfReaderTool.entries.toSet(), order.toSet()) assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name), loadPdfBottomTools(context)) - assertEquals(setOf(PdfReaderTool.PRINT.name), loadPdfHiddenTools(context)) + assertEquals( + setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name), + loadPdfHiddenTools(context) + ) } @Test @@ -44,6 +47,8 @@ class PdfReaderPreferencesTest { savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH)) assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context)) + assertFalse(PdfReaderTool.SCREEN_ORIENTATION.name in loadPdfHiddenTools(context)) + assertFalse(PdfReaderTool.HIGHLIGHT_ALL.name in loadPdfHiddenTools(context)) assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context)) assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2)) } diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt index 440bd4c..c6267a1 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt @@ -33,7 +33,9 @@ class PdfReaderSerializerTest { PdfPoint(0.2f, 0.3f, 11L) ), color = Color(0xFF336699), - strokeWidth = 0.0125f + strokeWidth = 0.0125f, + id = "ink-1", + note = "Desktop note" ) ), 2 to listOf( @@ -53,6 +55,8 @@ class PdfReaderSerializerTest { assertEquals(setOf(0, 2), decoded.keys) val first = decoded.getValue(0).single() assertEquals(AnnotationType.INK, first.type) + assertEquals("ink-1", first.id) + assertEquals("Desktop note", first.note) assertEquals(InkType.FOUNTAIN_PEN, first.inkType) assertEquals(Color(0xFF336699).toArgb(), first.color.toArgb()) assertEquals(0.0125f, first.strokeWidth, 0.00001f) @@ -81,6 +85,7 @@ class PdfReaderSerializerTest { assertEquals(AnnotationType.INK, decoded.type) assertEquals(InkType.PENCIL, decoded.inkType) + assertTrue(decoded.id.isNotBlank()) assertEquals(0L, decoded.points.single().timestamp) assertTrue(AnnotationSerializer.fromJson("not json").isEmpty()) assertTrue(AnnotationSerializer.fromJson("").isEmpty()) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt index 6c651cf..5f3c89b 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt @@ -14,6 +14,7 @@ import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -128,6 +129,21 @@ class PdfReaderSettingsAndSharedModelsTest { assertTrue(highlighter.strokeWidth > pen.strokeWidth) } + @Test + fun `SharedPdfHighlighterPalette preserves slots and normalizes alpha`() { + val palette = SharedPdfHighlighterPalette( + colors = listOf(0xFFFF0000.toInt()) + ).sanitized() + + assertEquals(SharedPdfHighlighterPalette.MaxColors, palette.colors.size) + assertEquals(0x8CFF0000.toInt(), palette.colors.first()) + assertTrue(palette.colors.all { (it ushr 24) == SharedPdfHighlighterPalette.DefaultAlpha }) + + val updated = palette.withColorAt(2, 0xFF123456.toInt()) + + assertEquals(0x8C123456.toInt(), updated.colors[2]) + } + @Test fun `PdfZoomSpec clamps scale and keeps render size under pixel budget`() { val spec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f, maxRenderPixels = 1_000_000) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt index 18d1c7e..c32a053 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt @@ -4,6 +4,7 @@ import android.content.Context import com.aryan.reader.SearchResult import com.aryan.reader.pdf.data.PdfMetaDao import com.aryan.reader.pdf.data.PdfMetadata +import com.aryan.reader.pdf.data.PdfSearchIndex import com.aryan.reader.pdf.data.PdfSearchMatch import com.aryan.reader.pdf.data.PdfTextDao import com.aryan.reader.pdf.data.PdfTextDatabase @@ -11,6 +12,7 @@ import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.SmartSearchResult import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -105,6 +107,29 @@ class PdfTextRepositoryTest { assertEquals("needle", matches[0].query) } + @Test + fun `indexReaderPage replaces existing page text before inserting new text`() = runTest { + val document = mockk() + val page = mockk(relaxed = true) + val textPage = mockk(relaxed = true) + + coEvery { document.openPage(0) } returns page + coEvery { page.openTextPage() } returns textPage + coEvery { textPage.textPageCountChars() } returns 11 + coEvery { textPage.textPageGetText(0, 11) } returns "hello world" + val insertedPageText = slot() + + repository.indexReaderPage("book", document, 0) + + coVerifyOrder { + dao.deletePageText("book", 0) + dao.insertPageText(capture(insertedPageText)) + } + assertEquals("book", insertedPageText.captured.bookId) + assertEquals(0, insertedPageText.captured.pageIndex) + assertEquals("hello world", insertedPageText.captured.content) + } + @Test fun `smart search emits paged result when page match count is large`() = runTest { coEvery { dao.countMatches("book", "content:common*") } returns 51 diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt new file mode 100644 index 0000000..5a86325 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/pdf/PdfZoomLockStateTest.kt @@ -0,0 +1,131 @@ +package com.aryan.reader.pdf + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PdfZoomLockStateTest { + + @Test + fun `paginated locked page waits to report camera until saved lock is applied`() { + val lockedState = Triple(2.25f, -12f, 32f) + + assertFalse( + shouldReportPdfPageCamera( + isZoomEnabled = true, + isVerticalScroll = false, + isScrollLocked = true, + lockedState = lockedState, + hasAppliedLockedState = false + ) + ) + + assertTrue( + shouldReportPdfPageCamera( + isZoomEnabled = true, + isVerticalScroll = false, + isScrollLocked = true, + lockedState = lockedState, + hasAppliedLockedState = true + ) + ) + } + + @Test + fun `paginated locked page initializes from saved camera`() { + val camera = initialPdfPageCamera( + isZoomEnabled = true, + isVerticalScroll = false, + isScrollLocked = true, + lockedState = Triple(2.25f, -12f, 32f) + ) + + assertEquals(2.25f, camera.first, 0.0001f) + assertEquals(-12f, camera.second.x, 0.0001f) + assertEquals(32f, camera.second.y, 0.0001f) + } + + @Test + fun `loading locked preferences primes active camera from saved state`() { + val camera = activePdfCameraAfterLockPreferenceLoad( + isScrollLocked = true, + lockedState = Triple(2.25f, -12f, 32f) + ) + + assertEquals(2.25f, camera.first, 0.0001f) + assertEquals(-12f, camera.second.x, 0.0001f) + assertEquals(32f, camera.second.y, 0.0001f) + } + + @Test + fun `paginated locked page can report camera when no saved lock exists yet`() { + assertTrue( + shouldReportPdfPageCamera( + isZoomEnabled = true, + isVerticalScroll = false, + isScrollLocked = true, + lockedState = null, + hasAppliedLockedState = false + ) + ) + } + + @Test + fun `bubble zoom cleanup does not reset zoom while scroll lock is on`() { + assertFalse( + shouldResetPdfZoomAfterBubbleZoomCleanup( + isBubbleZoomModeActive = false, + scale = 1.8f, + isVerticalScroll = false, + isZoomEnabled = true, + isScrollLocked = true + ) + ) + assertTrue( + shouldResetPdfZoomAfterBubbleZoomCleanup( + isBubbleZoomModeActive = false, + scale = 1.8f, + isVerticalScroll = false, + isZoomEnabled = true, + isScrollLocked = false + ) + ) + } + + @Test + fun `page change preserves locked zoom scale only in paginated lock mode`() { + val lockedState = Triple(2.25f, -12f, 32f) + + assertEquals( + 2.25f, + currentPageScaleAfterPdfPageChange( + displayMode = DisplayMode.PAGINATION, + isScrollLocked = true, + lockedState = lockedState, + currentActiveScale = 1f + ), + 0.0001f + ) + assertEquals( + 1f, + currentPageScaleAfterPdfPageChange( + displayMode = DisplayMode.PAGINATION, + isScrollLocked = false, + lockedState = lockedState, + currentActiveScale = 2.25f + ), + 0.0001f + ) + assertEquals( + 1f, + currentPageScaleAfterPdfPageChange( + displayMode = DisplayMode.VERTICAL_SCROLL, + isScrollLocked = true, + lockedState = lockedState, + currentActiveScale = 2.25f + ), + 0.0001f + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt b/app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt new file mode 100644 index 0000000..5176540 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/pptx/PptxDocumentParserTest.kt @@ -0,0 +1,312 @@ +package com.aryan.reader.pptx + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import com.aryan.reader.FileType +import com.aryan.reader.pdf.DocumentFactory +import io.legere.pdfiumandroid.suspend.PdfiumCoreKt +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class PptxDocumentParserTest { + + @Test + fun `parser resolves slide order inheritance and media relationships`() { + val file = createTinyPptx() + + val deck = PptxDocumentParser.parse(file) + + assertEquals(720, deck.widthPoint) + assertEquals(405, deck.heightPoint) + assertEquals(1, deck.slides.size) + assertTrue(deck.slides.single().text.contains("Master Text")) + assertTrue(deck.slides.single().text.contains("Layout Text")) + assertTrue(deck.slides.single().text.contains("Hello PPTX")) + assertTrue(deck.slides.single().text.contains("Inherited Placeholder")) + assertTrue(deck.slides.single().text.contains("Cell A")) + assertTrue(deck.slides.single().text.contains("Grouped Text")) + assertTrue(deck.slides.single().text.contains("1. First item")) + assertTrue(deck.slides.single().text.contains("2. Second item")) + assertTrue(deck.slides.single().text.contains("\u2022 Wingding bullet")) + assertFalse(deck.slides.single().text.contains("Layout Placeholder Prompt")) + val inheritedPlaceholder = deck.slides.single().elements + .filterIsInstance() + .single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Inherited Placeholder") } } } + assertEquals(PptxTextAlign.CENTER, inheritedPlaceholder.paragraphs.single().alignment) + assertEquals(24f, inheritedPlaceholder.paragraphs.single().runs.first().sizePt) + assertEquals(PptxAutoFitMode.NORMAL, inheritedPlaceholder.autoFitMode) + assertEquals(0.8f, inheritedPlaceholder.fontScale, 0.001f) + val centeredShape = deck.slides.single().elements + .filterIsInstance() + .single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Centered") } } } + assertEquals(PptxTextAlign.CENTER, centeredShape.paragraphs.single().alignment) + assertEquals(PptxVerticalAnchor.MIDDLE, centeredShape.verticalAnchor) + assertEquals(36f, centeredShape.paragraphs.single().runs.first().sizePt) + val table = deck.slides.single().elements.filterIsInstance().single() + assertEquals(1, table.rows.size) + assertEquals(PptxVerticalAnchor.MIDDLE, table.rows.single().cells.first().verticalAnchor) + assertTrue(table.rows.single().cells[1].fillColor != null) + assertTrue(table.rows.single().cells[1].lineColor != null) + val groupedShape = deck.slides.single().elements + .filterIsInstance() + .single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Grouped Text") } } } + assertTrue(groupedShape.bounds.left > 35f) + assertEquals(PptxAutoFitMode.SHAPE, groupedShape.autoFitMode) + val image = deck.slides.single().elements.filterIsInstance().single() + assertTrue(image.bytes.contentEquals(byteArrayOf(1, 2, 3, 4))) + assertTrue(image.crop.left > 0f) + assertEquals(0.35f, image.opacity, 0.001f) + assertTrue(deck.slides.single().elements.filterIsInstance().any { it.customGeometry != null }) + } + + @Test + fun `document wrapper exposes page geometry and indexed text`() = runTest { + val file = createTinyPptx() + PptxDocumentWrapper(file).use { document -> + assertEquals(1, document.getPageCount()) + val page = document.openPage(0)!! + page.use { + assertEquals(720, it.getPageWidthPoint()) + assertEquals(405, it.getPageHeightPoint()) + it.openTextPage().use { textPage -> + val count = textPage.textPageCountChars() + assertTrue(count > 0) + assertTrue(textPage.textPageGetText(0, count).orEmpty().contains("Hello PPTX")) + assertTrue(textPage.textPageGetRectsForRanges(intArrayOf(0, 5)).orEmpty().isNotEmpty()) + } + } + } + } + + @Test + fun `document factory routes pptx to native pptx wrapper`() = runTest { + val file = createTinyPptx() + val cacheDir = File.createTempFile("reader-pptx-cache", "").apply { + delete() + mkdirs() + deleteOnExit() + } + val contentResolver = mockk() + val context = mockk() + every { context.cacheDir } returns cacheDir + every { context.contentResolver } returns contentResolver + every { contentResolver.openInputStream(any()) } answers { file.inputStream() } + + val document = DocumentFactory.loadDocument( + context = context, + uri = Uri.fromFile(file), + type = FileType.PPTX, + password = null, + pdfiumCore = mockk(relaxed = true) + ) + + document.use { + assertTrue(it is PptxDocumentWrapper) + } + } + + private fun createTinyPptx(): File { + val file = File.createTempFile("reader-test", ".pptx").apply { deleteOnExit() } + ZipOutputStream(file.outputStream()).use { zip -> + zip.putText( + "ppt/presentation.xml", + """ + + + + + """.trimIndent() + ) + zip.putText( + "ppt/_rels/presentation.xml.rels", + """ + + + + """.trimIndent() + ) + zip.putText( + "ppt/slides/slide1.xml", + """ + + + + + + + Hello PPTX + + + + + + + + + Inherited Placeholder + + + + + Centered Small + + + + + First itemSecond item + + + + + Wingding bullet + + + + + + + + + + + {5C22544A-7EE6-4342-B048-85BDC9FD1C3A} + + Cell A + Cell B + + + + + + + + + + + Grouped Text + + + + + + """.trimIndent() + ) + zip.putText( + "ppt/slides/_rels/slide1.xml.rels", + """ + + + + + """.trimIndent() + ) + zip.putText( + "ppt/slideLayouts/slideLayout1.xml", + layoutPart() + ) + zip.putText( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + """ + + + + """.trimIndent() + ) + zip.putText( + "ppt/slideMasters/slideMaster1.xml", + textPart("Master Text") + ) + zip.putText( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + """ + + + + """.trimIndent() + ) + zip.putText( + "ppt/theme/theme1.xml", + """ + + + + + + + + """.trimIndent() + ) + zip.putText( + "ppt/tableStyles.xml", + """ + + + + + + + """.trimIndent() + ) + zip.putBytes("ppt/media/image1.png", byteArrayOf(1, 2, 3, 4)) + } + return file + } + + private fun textPart(text: String): String { + return """ + + + + $text + + + """.trimIndent() + } + + private fun layoutPart(): String { + return """ + + + + + Layout Text + + + + + Layout Placeholder Prompt + + + + """.trimIndent() + } + + private fun ZipOutputStream.putText(path: String, text: String) { + putNextEntry(ZipEntry(path)) + write(text.toByteArray()) + closeEntry() + } + + private fun ZipOutputStream.putBytes(path: String, bytes: ByteArray) { + putNextEntry(ZipEntry(path)) + write(bytes) + closeEntry() + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 4da76f0..adc34d7 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,5 +1,16 @@ +import org.gradle.api.GradleException +import org.gradle.api.DefaultTask +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskAction +import org.gradle.jvm.tasks.Jar import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.gradle.work.DisableCachingByDefault +import java.io.File plugins { alias(libs.plugins.kotlin.multiplatform) @@ -7,6 +18,454 @@ plugins { alias(libs.plugins.compose.multiplatform) } +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class CheckBundledWebViewRuntimeTask : DefaultTask() { + @get:Input + abstract val bundleRootPath: Property + + @get:Input + abstract val osName: Property + + @get:Input + abstract val osArch: Property + + @get:Input + abstract val requiredPaths: ListProperty + + @TaskAction + fun checkRuntime() { + val bundleRoot = File(bundleRootPath.get()) + val missingFiles = requiredPaths.get().filterNot { bundleRoot.resolve(it).exists() } + if (missingFiles.isNotEmpty()) { + throw GradleException( + "Missing bundled KCEF runtime at ${bundleRoot.absolutePath}. " + + "Expected ${missingFiles.joinToString()} for ${osName.get()} ${osArch.get()} desktop packages." + ) + } + } +} + +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { + @get:Input + abstract val bundleRootPath: Property + + @get:Input + abstract val libraryPath: Property + + @TaskAction + fun checkRuntime() { + val bundleRoot = File(bundleRootPath.get()) + val library = bundleRoot.resolve(libraryPath.get()) + if (!library.isFile) { + throw GradleException( + "Missing bundled Pdfium runtime at ${library.absolutePath}. " + + "Expected ${libraryPath.get()} inside ${bundleRoot.absolutePath}." + ) + } + } +} + +fun desktopOsId(osName: String = System.getProperty("os.name")): String { + val normalized = osName.lowercase() + return when { + normalized.startsWith("windows") -> "windows" + normalized == "linux" || normalized.contains("linux") -> "linux" + normalized.startsWith("mac") || normalized.contains("darwin") -> "macos" + else -> "other" + } +} + +fun desktopArchId(osArch: String = System.getProperty("os.arch")): String { + return when (osArch.lowercase()) { + "amd64", "x86_64", "x64" -> "x64" + "aarch64", "arm64" -> "arm64" + "x86", "i386", "i686" -> "x86" + else -> "unknown" + } +} + +fun desktopKcefBundleDirectoryName( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String { + return when (desktopOsId(osName)) { + "windows" -> "kcef-bundle" + "linux" -> "kcef-bundle-linux-${desktopArchId(osArch)}" + "macos" -> "kcef-bundle-macos-${desktopArchId(osArch)}" + else -> "kcef-bundle-${desktopArchId(osArch)}" + } +} + +fun bundledWebViewRequiredPaths(osName: String, osArch: String): List { + return when (desktopOsId(osName)) { + "windows" -> listOf("jcef.dll", "libcef.dll") + "linux" -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales") + "macos" -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework") + else -> emptyList() + } +} + +fun desktopPdfiumDirectoryName( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String { + return when (desktopOsId(osName)) { + "windows" -> "win-${desktopArchId(osArch)}-v8" + "linux" -> "linux-${desktopArchId(osArch)}-v8" + "macos" -> "mac-${desktopArchId(osArch)}-v8" + else -> "${desktopArchId(osArch)}-v8" + } +} + +fun desktopPdfiumLibraryPath( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String { + return when (desktopOsId(osName)) { + "windows" -> "bin/pdfium.dll" + "linux" -> "lib/libpdfium.so" + "macos" -> "lib/libpdfium.dylib" + else -> "lib/pdfium" + } +} + +fun desktopJdkToolName(toolName: String, osName: String = System.getProperty("os.name")): String { + return if (desktopOsId(osName) == "windows") "$toolName.exe" else toolName +} + +fun File.asDesktopJdkHome(): File { + val absolute = absoluteFile + return when { + absolute.isFile && absolute.parentFile?.name?.equals("bin", ignoreCase = true) == true -> + absolute.parentFile?.parentFile ?: absolute + + absolute.isDirectory && absolute.name.equals("bin", ignoreCase = true) -> + absolute.parentFile ?: absolute + + absolute.resolve("Contents/Home/bin").isDirectory -> + absolute.resolve("Contents/Home") + + else -> absolute + } +} + +fun File.desktopJdkTool(toolName: String, osName: String = System.getProperty("os.name")): File { + return resolve("bin/${desktopJdkToolName(toolName, osName)}") +} + +fun File.isDesktopPackagingJdk(osName: String = System.getProperty("os.name")): Boolean { + return desktopJdkTool("java", osName).isFile && + desktopJdkTool("jlink", osName).isFile && + desktopJdkTool("jpackage", osName).isFile +} + +fun File.desktopJdkMajorVersion(): Int? { + val releaseFile = resolve("release") + if (!releaseFile.isFile) return null + + val version = runCatching { + releaseFile.useLines { lines -> + lines.firstOrNull { it.startsWith("JAVA_VERSION=") } + ?.substringAfter("=") + ?.trim() + ?.trim('"') + } + }.getOrNull() ?: return null + + return version.removePrefix("1.").substringBefore(".").toIntOrNull() +} + +fun safeChildDirectories(root: File): List { + return runCatching { + root.listFiles()?.filter { it.isDirectory }.orEmpty() + }.getOrDefault(emptyList()) +} + +fun stableDesktopJdkCandidates(candidates: List, preferredMajorVersion: Int = 21): List { + return candidates + .map { it.asDesktopJdkHome() } + .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } + .sortedWith( + compareBy { if (it.desktopJdkMajorVersion() == preferredMajorVersion) 0 else 1 } + .thenBy { it.desktopJdkMajorVersion() ?: Int.MAX_VALUE } + .thenBy { it.absolutePath.lowercase() } + ) +} + +fun desktopPathJdkCandidates(osName: String = System.getProperty("os.name")): List { + return System.getenv("PATH") + ?.split(File.pathSeparator) + .orEmpty() + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { File(it).resolve(desktopJdkToolName("jpackage", osName)) } + .filter { it.isFile } + .mapNotNull { it.parentFile?.parentFile } + .toList() +} + +fun desktopGradleJdkCandidates(): List { + val userHome = System.getProperty("user.home")?.let(::File) ?: return emptyList() + return stableDesktopJdkCandidates(safeChildDirectories(userHome.resolve(".gradle/jdks"))) +} + +fun desktopPlatformJdkCandidates(osName: String = System.getProperty("os.name")): List { + val roots = when (desktopOsId(osName)) { + "windows" -> listOfNotNull( + System.getenv("ProgramFiles")?.let { File(it, "Java") }, + System.getenv("ProgramFiles")?.let { File(it, "Eclipse Adoptium") }, + System.getenv("ProgramFiles")?.let { File(it, "Microsoft") }, + System.getenv("ProgramFiles(x86)")?.let { File(it, "Java") } + ) + + "linux" -> listOf( + File("/usr/lib/jvm"), + File("/usr/java"), + File("/opt/java"), + File("/opt/jdk") + ) + + "macos" -> listOfNotNull( + File("/Library/Java/JavaVirtualMachines"), + System.getProperty("user.home")?.let { File(it, "Library/Java/JavaVirtualMachines") } + ) + + else -> emptyList() + } + + return stableDesktopJdkCandidates(roots + roots.flatMap(::safeChildDirectories)) +} + +fun findDesktopPackagingJavaHome( + explicitCandidates: List, + implicitCandidates: List, + osName: String = System.getProperty("os.name") +): File? { + val explicitJavaHome = explicitCandidates.firstOrNull { it.isNotBlank() } + if (explicitJavaHome != null) { + val candidate = File(explicitJavaHome).asDesktopJdkHome() + if (!candidate.isDesktopPackagingJdk(osName)) { + throw GradleException( + "Desktop packaging JDK must include java, jlink, and jpackage under " + + "${candidate.resolve("bin").absolutePath}. " + + "Set -PdesktopPackagingJavaHome= or DESKTOP_PACKAGING_JAVA_HOME to a full JDK." + ) + } + return candidate + } + + return implicitCandidates + .map { it.asDesktopJdkHome() } + .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } + .firstOrNull { it.isDesktopPackagingJdk(osName) } +} + +fun normalizeDesktopPackageVersion(rawVersion: String): String { + val coreVersion = rawVersion.trim() + .substringBefore("-") + .substringBefore("+") + .takeIf { it.isNotBlank() } + ?: "1.0.0" + val parts = coreVersion.split(".") + val numericParts = parts.map { it.toIntOrNull() } + val normalizedParts = when { + parts.size in 1..3 && numericParts.all { it != null } -> + numericParts.map { it ?: 0 } + List(3 - parts.size) { 0 } + + else -> throw GradleException( + "desktopPackageVersion must be numeric MAJOR[.MINOR[.BUILD]], but was '$rawVersion'." + ) + } + val (major, minor, build) = normalizedParts + if (major !in 0..255 || minor !in 0..255 || build !in 0..65535) { + throw GradleException( + "desktopPackageVersion '$rawVersion' is outside the Windows package version range. " + + "Expected MAJOR 0..255, MINOR 0..255, BUILD 0..65535." + ) + } + return "$major.$minor.$build" +} + +fun normalizeDesktopVersionName(rawVersion: String): String { + return rawVersion.trim().takeIf { it.isNotBlank() } ?: "1.0.0" +} + +fun normalizeDesktopFlavor(rawFlavor: String): String { + val flavor = rawFlavor.trim().lowercase() + return when (flavor) { + "oss", "oss-offline", "episteme-oss" -> "oss-offline" + else -> "standard" + } +} + +fun normalizeDesktopPackageArchitecture(osArch: String): String { + val normalizedArch = desktopArchId(osArch) + return if (normalizedArch != "unknown") { + normalizedArch + } else { + osArch.lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifBlank { "unknown" } + } +} + +fun renameDesktopMsiOutput( + msiDirectory: File, + packageName: String, + packageVersion: String, + architecture: String +) { + val source = msiDirectory.resolve("$packageName-$packageVersion.msi") + if (!source.isFile) return + + val target = msiDirectory.resolve("$packageName-$packageVersion-$architecture.msi") + if (target.exists() && !target.delete()) { + throw GradleException("Could not replace existing MSI at ${target.absolutePath}.") + } + if (!source.renameTo(target)) { + throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.") + } +} + +val desktopVersionName = "1.0.0" +val desktopFlavor = providers.gradleProperty("desktopFlavor") + .orElse("standard") + .map(::normalizeDesktopFlavor) + .get() +val isOssOfflineDesktop = desktopFlavor == "oss-offline" +val desktopDiagnostics = providers.gradleProperty("desktopDiagnostics") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) +val desktopDiagnosticTags = providers.gradleProperty("desktopDiagnosticTags") + .orElse("") +val desktopResolvedVersionName = providers.gradleProperty("desktopVersionName") + .orElse(providers.gradleProperty("desktopVersion")) + .orElse(desktopVersionName) + .map(::normalizeDesktopVersionName) +val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion") + .orElse(desktopResolvedVersionName) + .map(::normalizeDesktopPackageVersion) +val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" +val desktopPackageDescription = if (isOssOfflineDesktop) { + "Episteme oss offline desktop reader" +} else { + "Episteme desktop reader" +} +val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") +val desktopOsName = System.getProperty("os.name") +val desktopOsArch = System.getProperty("os.arch") +val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) +val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources") +val bundledWebViewDir = layout.projectDirectory.dir(desktopKcefBundleDirectoryName(desktopOsName, desktopOsArch)) +val bundledPdfiumDir = layout.projectDirectory.dir( + "../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}" +) +val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch) +val bundledWebViewKeptLocales = setOf( + "ar.pak", + "de.pak", + "en-GB.pak", + "en-US.pak", + "es-419.pak", + "es.pak", + "fr.pak", + "hi.pak", + "pt-BR.pak", + "ru.pak", + "tr.pak", + "vi.pak" +) +val bundledWebViewTrimmedRuntimeFiles = listOf( + "ct.sym", + "jawt.lib", + "jvm.lib", + "jaccessinspector.exe", + "jaccesswalker.exe", + "jabswitch.exe", + "javac.exe", + "javadoc.exe", + "jcmd.exe", + "jdb.exe", + "jfr.exe", + "jhsdb.exe", + "jinfo.exe", + "jmap.exe", + "jps.exe", + "jrunscript.exe", + "jstack.exe", + "jstat.exe", + "jwebserver.exe", + "keytool.exe", + "kinit.exe", + "klist.exe", + "ktab.exe", + "rmiregistry.exe", + "serialver.exe", + "server/classes.jsa", + "server/classes_nocoops.jsa" +) +val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico") +val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png") +val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) { + "ca13b201-940a-420a-8a3f-16e7d83d12a8" +} else { + "c04c5823-b25a-4f38-a1cf-0da7b02ac397" +} +val desktopPackagingJavaHome = findDesktopPackagingJavaHome( + explicitCandidates = listOfNotNull( + providers.gradleProperty("desktopPackagingJavaHome").orNull, + providers.environmentVariable("DESKTOP_PACKAGING_JAVA_HOME").orNull, + providers.environmentVariable("JPACKAGE_HOME").orNull + ), + implicitCandidates = buildList { + addAll(desktopGradleJdkCandidates()) + providers.gradleProperty("org.gradle.java.home").orNull?.let { add(File(it)) } + providers.environmentVariable("GRADLE_LOCAL_JAVA_HOME").orNull?.let { add(File(it)) } + providers.environmentVariable("JAVA_HOME").orNull?.let { add(File(it)) } + providers.environmentVariable("JDK_HOME").orNull?.let { add(File(it)) } + add(File(System.getProperty("java.home"))) + addAll(desktopPathJdkCandidates(desktopOsName)) + addAll(desktopPlatformJdkCandidates(desktopOsName)) + }, + osName = desktopOsName +)?.absolutePath + +val checkBundledWebViewRuntime by tasks.registering(CheckBundledWebViewRuntimeTask::class) { + val requiredPaths = bundledWebViewRequiredPaths(desktopOsName, desktopOsArch) + bundleRootPath.set(bundledWebViewDir.asFile.absolutePath) + osName.set(desktopOsName) + osArch.set(desktopOsArch) + this.requiredPaths.set(requiredPaths) +} + +val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) { + bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath) + libraryPath.set(bundledPdfiumLibraryPath) +} + +val prepareBundledDesktopResources by tasks.registering(Sync::class) { + dependsOn(checkBundledWebViewRuntime, checkBundledPdfiumRuntime) + from(bundledWebViewDir) { + exclude(bundledWebViewTrimmedRuntimeFiles) + val localeExcludes = bundledWebViewDir.asFile + .resolve("locales") + .listFiles { file -> file.isFile && file.extension.equals("pak", ignoreCase = true) } + .orEmpty() + .map { it.name } + .filterNot { it in bundledWebViewKeptLocales } + .map { "locales/$it" } + exclude(localeExcludes) + into("common/kcef-bundle") + } + from(bundledPdfiumDir) { + into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}") + } + into(generatedDesktopResourcesDir) +} + kotlin { jvm("desktop") jvmToolchain(21) @@ -22,6 +481,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("net.java.dev.jna:jna:5.17.0") implementation("org.apache.commons:commons-compress:1.28.0") + implementation("org.apache.thrift:libthrift:0.22.0") implementation("org.tukaani:xz:1.10") implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1") } @@ -36,28 +496,113 @@ kotlin { compose.desktop { application { - mainClass = "com.aryan.reader.desktop.MainKt" + mainClass = "com.aryan.reader.desktop.LauncherKt" + desktopPackagingJavaHome?.let { javaHome = it } jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") + jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") + jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") + jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") + + buildTypes.release.proguard { + obfuscate.set(false) + // Compose/Kotlin generated methods can produce very large stack-map frames. + // ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds. + optimize.set(false) + configurationFiles.from(project.file("compose-desktop.pro")) + } nativeDistributions { - targetFormats(TargetFormat.Exe, TargetFormat.Msi) - packageName = "Episteme" - packageVersion = "1.0.0" - description = "Episteme desktop shell" - vendor = "Aryan Reader" + targetFormats(TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) + modules("java.net.http") + packageName = desktopPackageName + packageVersion = desktopPackageVersion.get() + description = desktopPackageDescription + vendor = desktopVendor.get() + appResourcesRootDir.set(generatedDesktopResourcesDir) + windows { + iconFile.set(desktopWindowsIconFile) + dirChooser = true + shortcut = true + menu = true + menuGroup = "Episteme" + perUserInstall = true + upgradeUuid = desktopWindowsUpgradeUuid + } + linux { + iconFile.set(desktopLinuxIconFile) + packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" + debMaintainer = "epistemereader@gmail.com" + menuGroup = "Office" + appCategory = "Office" + } } } } -afterEvaluate { - tasks.withType().configureEach { - jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") - if (System.getProperty("os.name").contains("Mac")) { - jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") +tasks.withType().configureEach { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") + jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") + jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") + jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") + if (System.getProperty("os.name").contains("Mac")) { + jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + } +} + +tasks.withType().configureEach { + manifest { + attributes( + "Implementation-Title" to desktopPackageName, + "Implementation-Version" to desktopResolvedVersionName.get(), + "Implementation-Vendor" to desktopVendor.get() + ) + } +} + +mapOf( + "packageMsi" to "main", + "packageReleaseMsi" to "main-release" +).forEach { (taskName, distributionName) -> + tasks.matching { it.name == taskName }.configureEach { + doLast { + renameDesktopMsiOutput( + msiDirectory = layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile, + packageName = desktopPackageName, + packageVersion = desktopPackageVersion.get(), + architecture = desktopPackageArchitecture + ) } } } + +tasks.matching { + it.name in setOf( + "createDistributable", + "createReleaseDistributable", + "prepareAppResources", + "prepareReleaseAppResources", + "packageDistributionForCurrentOS", + "packageReleaseDistributionForCurrentOS", + "packageExe", + "packageReleaseExe", + "packageMsi", + "packageReleaseMsi", + "packageDeb", + "packageReleaseDeb", + "packageRpm", + "packageReleaseRpm", + "runDistributable", + "runReleaseDistributable" + ) +}.configureEach { + dependsOn(prepareBundledDesktopResources) + inputs.dir(generatedDesktopResourcesDir) + .withPropertyName("bundledDesktopResources") + .withPathSensitivity(PathSensitivity.RELATIVE) +} diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro new file mode 100644 index 0000000..bfec64c --- /dev/null +++ b/desktopApp/compose-desktop.pro @@ -0,0 +1,32 @@ +-keep class org.cef.** { *; } +-keep class org.apache.thrift.** { *; } +-keep class io.ktor.serialization.kotlinx.** { *; } +-keep class io.ktor.serialization.kotlinx.json.** { *; } +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.Library { *; } +-keep class * extends com.sun.jna.Structure { *; } +-keep class kotlinx.coroutines.swing.SwingDispatcherFactory + +# Desktop release shrinking sees optional integrations from JCEF/KCEF, JOGL, Commons +# Compress Pack200, and OkHttp platform probes. These references are not bundled for +# the Windows MSI path, so keep ProGuard from treating them as release blockers. +-dontwarn com.jetbrains.cef.** +-dontwarn com.jetbrains.JBR +-dontwarn org.cef.** +-dontwarn com.jogamp.** +-dontwarn jogamp.** +-dontwarn org.apache.commons.compress.harmony.pack200.** +-dontwarn org.objectweb.asm.** +-dontwarn org.apache.thrift.** +-dontwarn io.ktor.serialization.kotlinx.** +-dontwarn com.sun.jna.** +-dontwarn org.eclipse.swt.** +-dontwarn javafx.** +-dontwarn com.sun.javafx.** +-dontwarn okhttp3.internal.platform.** +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** +-dontwarn android.** +-dontwarn com.github.luben.zstd.** +-dontwarn org.brotli.dec.** diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt index 2e18f3d..a2905e6 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt @@ -28,22 +28,25 @@ internal class DesktopAiByokStore( } fun load(): ReaderAiByokSettings { + val settingsFileExists = settingsFile.exists() logDesktopTts( "settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + - "exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}" + "exists=$settingsFileExists secureStorage=${if (settingsFileExists) "checking" else "skipped"}" ) - if (!settingsFile.exists()) { + if (!settingsFileExists) { logDesktopTts("settings_load_empty reason=file_missing") return ReaderAiByokSettings() } + val secureStorageAvailable = secretCodec.isAvailable + logDesktopTts("settings_load_secure_storage codec=${secretCodec.name} available=$secureStorageAvailable") val properties = Properties() return runCatching { settingsFile.inputStream().use(properties::load) val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "") val legacyGroqKey = properties.getProperty(LegacyGroqKey, "") val loadedSettings = ReaderAiByokSettings( - geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey), - groqKey = loadSecret(properties, GroqKey, legacyGroqKey), + geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey, secureStorageAvailable), + groqKey = loadSecret(properties, GroqKey, legacyGroqKey, secureStorageAvailable), useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true, modelForAll = properties.getProperty("modelForAll", ""), defineModel = properties.getProperty("defineModel", ""), @@ -58,7 +61,7 @@ internal class DesktopAiByokStore( } else { loadedSettings } - if (secretCodec.isAvailable && + if (secureStorageAvailable && (legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings) ) { logDesktopTts( @@ -107,7 +110,12 @@ internal class DesktopAiByokStore( ) } - private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String { + private fun loadSecret( + properties: Properties, + key: String, + legacyPlaintext: String, + secureStorageAvailable: Boolean + ): String { val protectedValue = properties.getProperty(key, "") val decrypted = protectedValue .takeIf { it.isNotBlank() } @@ -118,7 +126,7 @@ internal class DesktopAiByokStore( } .orEmpty() if (decrypted.isNotBlank()) return decrypted - return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty() + return legacyPlaintext.takeIf { secureStorageAvailable }.orEmpty() } private fun Properties.setProtectedSecret(key: String, value: String) { @@ -148,9 +156,7 @@ internal class DesktopAiByokStore( private const val LegacyGroqKey = "groqKey" fun defaultSettingsFile(): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath - return File(baseDir, "Episteme/ai-byok.properties") + return File(desktopUserConfigRoot(), "ai-byok.properties") } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt new file mode 100644 index 0000000..24263f4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBookImporter.kt @@ -0,0 +1,104 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.ImportedBookFile +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +internal data class DesktopPreparedImport( + val files: List, + val failedCount: Int +) + +internal class DesktopBookImporter( + private val booksDirectory: File = File(desktopUserDataRoot(), "books") +) { + fun prepareImports(files: List): DesktopPreparedImport { + val preparedFiles = mutableListOf() + var failedCount = 0 + booksDirectory.mkdirs() + + files.forEach { file -> + val type = SharedFileCapabilities.fileTypeForName(file.name) + if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) { + preparedFiles += file.copy(sourceFolder = null) + return@forEach + } + + val source = file.localPath + ?.let(::File) + ?.takeIf { it.isFile } + + if (source == null) { + failedCount += 1 + return@forEach + } + + val hashResult = runCatching { source.sha256() } + if (hashResult.isFailure) { + failedCount += 1 + return@forEach + } + val hash = hashResult.getOrThrow() + val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}") + + val copyResult = runCatching { + copyIfNeeded(source, destination) + destination + } + if (copyResult.isFailure) { + failedCount += 1 + return@forEach + } + val copied = copyResult.getOrThrow() + + preparedFiles += ImportedBookFile( + name = file.name, + uriString = null, + localPath = copied.absolutePath, + size = copied.length(), + sourceFolder = null, + id = hash + ) + } + + return DesktopPreparedImport( + files = preparedFiles, + failedCount = failedCount + ) + } + + private fun copyIfNeeded(source: File, destination: File) { + val sourceFile = source.canonicalFile + val destinationFile = destination.canonicalFile + if (sourceFile == destinationFile) return + destination.parentFile?.mkdirs() + Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + private fun ImportedBookFile.storageSuffix(source: File): String { + return SharedFileCapabilities.fileExtensionSuffixForName(name) + ?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" } + ?: ".book" + } +} + +private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(8 * 1024) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return digest.digest().toHexString() +} + +private fun ByteArray.toHexString(): String { + return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt new file mode 100644 index 0000000..ec1ac46 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt @@ -0,0 +1,105 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.ReaderAiByokSettings +import com.aryan.reader.shared.SharedFeaturePolicy +import java.io.File + +internal const val DesktopFlavorProperty = "episteme.desktop.flavor" +internal const val DesktopVersionProperty = "episteme.desktop.version" +internal const val DesktopFlavorStandard = "standard" +internal const val DesktopFlavorOssOffline = "oss-offline" +internal const val EpistemeDesktopStandardAppName = "Episteme" +internal const val EpistemeDesktopOssAppName = "Episteme oss" +internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir" + +internal data class DesktopBuildProfile( + val flavor: String, + val appName: String, + val buildLabel: String, + val featurePolicy: SharedFeaturePolicy +) { + val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline +} + +internal fun currentDesktopBuildProfile(): DesktopBuildProfile { + return desktopBuildProfileForFlavor( + System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard) + ) +} + +internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile { + val flavor = normalizedDesktopFlavor(rawFlavor) + return when (flavor) { + DesktopFlavorOssOffline -> DesktopBuildProfile( + flavor = DesktopFlavorOssOffline, + appName = EpistemeDesktopOssAppName, + buildLabel = "Offline OSS edition", + featurePolicy = SharedFeaturePolicy.OssOffline + ) + else -> DesktopBuildProfile( + flavor = DesktopFlavorStandard, + appName = EpistemeDesktopStandardAppName, + buildLabel = "Standard edition", + featurePolicy = SharedFeaturePolicy.Standard + ) + } +} + +private fun normalizedDesktopFlavor(rawFlavor: String?): String { + return when (rawFlavor?.trim()?.lowercase()) { + DesktopFlavorOssOffline, + "oss", + "episteme-oss" -> DesktopFlavorOssOffline + else -> DesktopFlavorStandard + } +} + +internal fun ReaderAiByokSettings.withDesktopFeaturePolicy( + featurePolicy: SharedFeaturePolicy +): ReaderAiByokSettings { + return if (featurePolicy.aiAndCloud) { + sanitized() + } else { + ReaderAiByokSettings(hideReaderAiFeatures = true) + } +} + +internal fun bundledDesktopWebViewDir(): File { + val platform = currentDesktopPlatform() + val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty) + ?.takeIf { it.isNotBlank() } + ?.let(::File) + return listOfNotNull( + resourceDir?.resolve("kcef-bundle"), + File(System.getProperty("user.dir"), "kcef-bundle"), + File(System.getProperty("user.dir"), "desktopApp/${platform.kcefBundleDirectoryName}"), + File(System.getProperty("user.dir"), "desktopApp/kcef-bundle"), + File("desktopApp/${platform.kcefBundleDirectoryName}"), + File("desktopApp/kcef-bundle"), + File(platform.kcefBundleDirectoryName), + File("kcef-bundle") + ).firstOrNull(::isBundledDesktopWebViewPresent) + ?: resourceDir?.resolve("kcef-bundle") + ?: File(platform.kcefBundleDirectoryName) +} + +internal fun isBundledDesktopWebViewPresent( + dir: File, + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return dir.isDirectory && + bundledDesktopWebViewRequiredPaths(platform).all { requiredPath -> + dir.resolve(requiredPath).exists() + } +} + +internal fun bundledDesktopWebViewRequiredPaths( + platform: DesktopPlatform = currentDesktopPlatform() +): List { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> listOf("jcef.dll", "libcef.dll") + DesktopOperatingSystem.LINUX -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales") + DesktopOperatingSystem.MACOS -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework") + DesktopOperatingSystem.OTHER -> emptyList() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt index 85b1840..c90eb36 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt @@ -27,10 +27,11 @@ import java.net.HttpURLConnection import java.net.URL class DesktopByokAiAdapter( - private val settingsProvider: () -> ReaderAiByokSettings + private val settingsProvider: () -> ReaderAiByokSettings, + private val networkAccess: () -> Boolean = { true } ) : AiAdapter { override val isAvailable: Boolean - get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable + get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable override suspend fun define(text: String, context: String?): AiDefinitionResult { val result = callTextAi(ReaderAiFeature.DEFINE, text, context) @@ -52,6 +53,7 @@ class DesktopByokAiAdapter( text: String, context: String? = null ): Result = withContext(Dispatchers.IO) { + if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build.")) if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send.")) when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) { ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden.")) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt index 8988ab9..e5ef065 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt @@ -110,7 +110,7 @@ internal object DesktopComicArchive { .getOrElse { commandError -> error( "Could not open CBR with libarchive. " + - "Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " + + "Bundle libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " + "Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " + "Command: ${commandError.shortMessage()}" ) @@ -558,7 +558,7 @@ private object DesktopLibarchive { .getOrNull() } ?: error( "Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " + - "or bundle archive.dll/libarchive for this platform." + "or bundle libarchive for this platform." ) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt index 406eeaf..5c3e45f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt @@ -11,7 +11,8 @@ import java.net.URL import java.util.UUID class DesktopCustomFontStore( - private val fontsDir: File = defaultFontsDir() + private val fontsDir: File = defaultFontsDir(), + private val googleFontsDownloadAvailable: () -> Boolean = { true } ) { private var googleFontsCache: List? = null @@ -67,6 +68,9 @@ class DesktopCustomFontStore( } fun downloadGoogleFont(fontName: String): Result { + if (!googleFontsDownloadAvailable()) { + return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build.")) + } val normalizedFontName = fontName.trim() if (normalizedFontName.isBlank()) { return Result.failure(IllegalArgumentException("Choose a Google Font.")) @@ -114,9 +118,7 @@ class DesktopCustomFontStore( "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1" fun defaultFontsDir(): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath - return File(baseDir, "Episteme/custom_fonts") + return File(desktopUserDataRoot(), "custom_fonts") } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt new file mode 100644 index 0000000..35a2855 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt @@ -0,0 +1,16 @@ +package com.aryan.reader.desktop + +internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" + +internal val DesktopDiagnosticsEnabled: Boolean = + desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) + +internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { + return rawValue?.trim()?.equals("true", ignoreCase = true) == true +} + +internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) { + if (DesktopDiagnosticsEnabled) { + println("$tag ${message()}") + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt index c2e1d11..77e5f27 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt @@ -69,6 +69,11 @@ object DesktopFolderMetadataExtractor { return enrichBooks(books) { book -> book.id in importedBookIds } } + fun enrichOpenedBook(book: BookItem): BookItem { + if (!book.needsFolderMetadataExtraction()) return book + return runCatching { enrichBook(book) }.getOrDefault(book) + } + private fun enrichBooks( books: List, shouldConsider: (BookItem) -> Boolean @@ -107,27 +112,35 @@ object DesktopFolderMetadataExtractor { private fun enrichBook(book: BookItem): BookItem { val file = File(book.path.orEmpty()) val size = file.length().takeIf { it > 0L } ?: book.fileSize - var title = book.title - var author = book.author + var extractedTitle: String? = null + var extractedAuthor: String? = null + var extractedDescription: String? = null + var extractedSeriesName: String? = null + var extractedSeriesIndex: Double? = null var textMetadataParsed = book.folderTextMetadataParsed var embeddedCover: EmbeddedCover? = null when (book.type) { FileType.EPUB -> { val metadata = parseEpubMetadata(file) - title = sanitizeTitle(metadata.title) ?: title - author = sanitizeAuthor(metadata.author) ?: author + extractedTitle = sanitizeTitle(metadata.title) + extractedAuthor = sanitizeAuthor(metadata.author) + extractedDescription = sanitizeDescription(metadata.description) + extractedSeriesName = sanitizeDescription(metadata.seriesName) + extractedSeriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 } embeddedCover = metadata.cover textMetadataParsed = true } FileType.PDF -> { val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull() - title = sanitizeTitle(metadata?.title) ?: title - author = sanitizeAuthor(metadata?.author) ?: author + extractedTitle = sanitizeTitle(metadata?.title) + extractedAuthor = sanitizeAuthor(metadata?.author) + extractedDescription = sanitizeDescription(metadata?.description) textMetadataParsed = true } FileType.HTML -> { - title = sanitizeTitle(parseHtmlTitle(file)) ?: title + extractedTitle = sanitizeTitle(parseHtmlTitle(file)) + extractedDescription = sanitizeDescription(parseHtmlDescription(file)) textMetadataParsed = true } FileType.MOBI, @@ -137,8 +150,8 @@ object DesktopFolderMetadataExtractor { FileType.FODT -> { runCatching { SharedJvmBookLoader.load(file, book.type) } .onSuccess { loaded -> - title = sanitizeTitle(loaded.title) ?: title - author = sanitizeAuthor(loaded.author) ?: author + extractedTitle = sanitizeTitle(loaded.title) + extractedAuthor = sanitizeAuthor(loaded.author) textMetadataParsed = true } } @@ -150,10 +163,45 @@ object DesktopFolderMetadataExtractor { ?: renderReaderSurfaceCover(book, file) ?: saveGeneratedCover(book) + val nextTitle = if (book.shouldApplyExtractedTitle(file)) { + extractedTitle ?: book.title ?: file.nameWithoutExtension + } else { + book.title + } + val nextAuthor = if (book.shouldApplyExtractedText(book.author, book.originalAuthor)) { + extractedAuthor ?: book.author + } else { + book.author + } + val nextDescription = if (book.shouldApplyExtractedText(book.description, book.originalDescription)) { + extractedDescription ?: book.description + } else { + book.description + } + val nextSeriesName = if (book.shouldApplyExtractedText(book.seriesName, book.originalSeriesName)) { + extractedSeriesName ?: book.seriesName + } else { + book.seriesName + } + val nextSeriesIndex = if (book.seriesIndex == null || book.seriesIndex == book.originalSeriesIndex) { + extractedSeriesIndex ?: book.seriesIndex + } else { + book.seriesIndex + } + return book.copy( - title = title ?: file.nameWithoutExtension, - author = author, + title = nextTitle, + author = nextAuthor, + description = nextDescription, + seriesName = nextSeriesName, + seriesIndex = nextSeriesIndex, + originalTitle = book.originalTitle ?: extractedTitle, + originalAuthor = book.originalAuthor ?: extractedAuthor, + originalSeriesName = book.originalSeriesName ?: extractedSeriesName, + originalSeriesIndex = book.originalSeriesIndex ?: extractedSeriesIndex, + originalDescription = book.originalDescription ?: extractedDescription, fileSize = size, + fileContentModifiedTimestamp = file.lastModified(), coverImagePath = coverPath, folderTextMetadataParsed = textMetadataParsed ) @@ -184,6 +232,9 @@ object DesktopFolderMetadataExtractor { return ExtractedBookMetadata( title = opf.tagText("title"), author = opf.tagText("creator"), + description = opf.tagInnerContent("description"), + seriesName = opf.metaContent("calibre:series"), + seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(), cover = cover ) } @@ -250,6 +301,35 @@ object DesktopFolderMetadataExtractor { }.getOrNull() } + private fun parseHtmlDescription(file: File): String? { + return runCatching { + val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader -> + buildString { + var remaining = 64 * 1024 + val buffer = CharArray(2048) + while (remaining > 0) { + val read = reader.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + append(buffer, 0, read) + remaining -= read + if (contains("", ignoreCase = true)) break + } + } + } + Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(head) + .firstOrNull { meta -> + val name = meta.value.attr("name") + val property = meta.value.attr("property") + name.equals("description", ignoreCase = true) || + property.equals("og:description", ignoreCase = true) + } + ?.value + ?.attr("content") + ?.decodeEntities() + }.getOrNull() + } + private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? { if (cover == null || cover.bytes.isEmpty()) return null val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null @@ -413,9 +493,7 @@ object DesktopFolderMetadataExtractor { if (!overridePath.isNullOrBlank()) { return File(overridePath).apply { mkdirs() } } - val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile - ?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme") - return File(root, "cover_cache").apply { mkdirs() } + return File(desktopUserCacheRoot(), "cover_cache").apply { mkdirs() } } private fun ZipFile.readTextOrNull(path: String): String? { @@ -451,6 +529,32 @@ object DesktopFolderMetadataExtractor { .orEmpty() } + private fun String.tagInnerContent(tag: String): String { + return Regex( + "<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) + .find(this) + ?.groupValues + ?.get(1) + ?.trim() + ?.removeSurrounding("") + ?.decodeEntities() + ?.trim() + .orEmpty() + } + + private fun String.metaContent(name: String): String? { + return Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(this) + .firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) } + ?.value + ?.attr("content") + ?.decodeEntities() + ?.trim() + ?.takeIf { it.isNotBlank() } + } + private fun String.decodeEntities(): String { return replace(" ", " ") .replace("&", "&") @@ -490,6 +594,23 @@ object DesktopFolderMetadataExtractor { ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } } + private fun sanitizeDescription(value: String?): String? { + return value + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } + + private fun BookItem.shouldApplyExtractedTitle(file: File): Boolean { + val current = title?.trim() + val fallback = file.nameWithoutExtension + return current.isNullOrBlank() || current == fallback || current == originalTitle?.trim() + } + + private fun BookItem.shouldApplyExtractedText(current: String?, original: String?): Boolean { + val normalized = current?.trim() + return normalized.isNullOrBlank() || normalized == original?.trim() + } + private val EpubManifestItem.isRasterCover: Boolean get() = rasterExtension != null @@ -513,6 +634,9 @@ object DesktopFolderMetadataExtractor { private data class ExtractedBookMetadata( val title: String? = null, val author: String? = null, + val description: String? = null, + val seriesName: String? = null, + val seriesIndex: Double? = null, val cover: EmbeddedCover? = null ) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt new file mode 100644 index 0000000..818f43d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncLog.kt @@ -0,0 +1,19 @@ +package com.aryan.reader.desktop + +private const val DesktopFolderSyncLogTag = "EpistemeFolderSync" + +internal fun logDesktopFolderSync(message: String) { + logDesktopDiagnostic(DesktopFolderSyncLogTag) { message } +} + +internal fun Throwable.folderSyncSummary(): String { + val type = this::class.java.simpleName.ifBlank { "Throwable" } + return "$type: ${message.orEmpty().folderSyncPreview(220)}" +} + +internal fun String.folderSyncPreview(maxLength: Int = 160): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt index c2d88ae..423564f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt @@ -53,9 +53,15 @@ private data class DesktopTtsSequenceChunk( class DesktopGeminiCloudTtsAdapter( private val settingsProvider: () -> ReaderAiByokSettings, - private val httpClient: HttpClient = HttpClient.newHttpClient(), + private val networkAccess: () -> Boolean = { true }, + httpClient: HttpClient? = null, private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot()) ) : TtsAdapter { + private val providedHttpClient = httpClient + private val httpClient: HttpClient by lazy(LazyThreadSafetyMode.PUBLICATION) { + providedHttpClient ?: HttpClient.newHttpClient() + } + @Volatile private var activeLine: SourceDataLine? = null @@ -66,7 +72,7 @@ class DesktopGeminiCloudTtsAdapter( private var activePlayer: DesktopStreamingPcmPlayer? = null override val isAvailable: Boolean - get() = settingsProvider().sanitized().isCloudTtsAvailable + get() = networkAccess() && settingsProvider().sanitized().isCloudTtsAvailable override suspend fun speak(text: String) { val trimmed = text.trim() @@ -171,6 +177,10 @@ class DesktopGeminiCloudTtsAdapter( "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " + "available=${settings.isCloudTtsAvailable}" ) + if (!networkAccess()) { + logDesktopTts("stream_blocked reason=network_disabled") + throw IllegalStateException("Cloud TTS is unavailable in this desktop build.") + } if (!settings.isCloudTtsAvailable) { logDesktopTts("stream_blocked reason=not_available") throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.") @@ -447,9 +457,7 @@ private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer) } private fun defaultDesktopTtsCacheRoot(): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath - return File(baseDir, "Episteme/TTS_Cache") + return File(desktopUserCacheRoot(), "TTS_Cache") } private fun buildGeminiTtsSetup(speakerId: String): String { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt index 28906ca..0d60e2a 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt @@ -19,9 +19,7 @@ class DesktopLibraryDatabase( companion object { fun defaultDatabaseFile(): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath - return File(baseDir, "Episteme/library.json") + return File(desktopUserDataRoot(), "library.json") } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt index 9dc01cd..d99fc9e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt @@ -4,6 +4,7 @@ import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.BookShelfRef import com.aryan.reader.shared.FileType import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX +import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR import com.aryan.reader.shared.LocalFolderSyncEngine import com.aryan.reader.shared.LocalFolderSyncStats @@ -13,6 +14,11 @@ import com.aryan.reader.shared.SharedFolderBookMetadata import com.aryan.reader.shared.SharedFolderScannedFile import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.SyncedFolder +import com.aryan.reader.shared.localFolderSyncAnnotationFileName +import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName +import com.aryan.reader.shared.localFolderSyncMetadataFileName +import com.aryan.reader.shared.localFolderSyncMetadataTempFileName +import com.aryan.reader.shared.localFolderSyncSidecarStem import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import com.aryan.reader.shared.pdf.SharedPdfRichTextLog @@ -24,6 +30,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull @@ -60,9 +67,16 @@ object DesktopLocalFolderSync { state: SharedReaderScreenState, shelfRefs: List, targetFolder: File? = null, - nowMillis: Long = System.currentTimeMillis() + nowMillis: Long = System.currentTimeMillis(), + metadataOnly: Boolean = false ): DesktopLocalFolderSyncResult { val requestedFolders = foldersToSync(state, targetFolder, nowMillis) + val mode = if (metadataOnly) "metadata" else "full" + logDesktopFolderSync( + "sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + + "requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " + + "books=${state.rawLibraryBooks.size}" + ) var nextState = state var nextShelfRefs = shelfRefs var totalStats = LocalFolderSyncStats() @@ -74,18 +88,36 @@ object DesktopLocalFolderSync { requestedFolders.forEach { folder -> val root = File(folder.uriString) if (!root.isDirectory) { + logDesktopFolderSync( + "folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "root=\"${root.absolutePath.folderSyncPreview()}\"" + ) failedFolders += folder.name return@forEach } - val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString) + logDesktopFolderSync( + "folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}" + ) + val scannedFiles = if (metadataOnly) { + emptyList() + } else { + scanFolder(root = root, sourceFolder = folder.uriString) + } val remoteMetadata = readAllMetadata(root) + logDesktopFolderSync( + "folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " + + "remoteMetadata=${remoteMetadata.size}" + ) val syncResult = LocalFolderSyncEngine.syncFolder( state = nextState, folder = folder, files = scannedFiles, remoteMetadata = remoteMetadata, - nowMillis = nowMillis + nowMillis = nowMillis, + metadataOnly = metadataOnly ) nextState = syncResult.state nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs( @@ -95,25 +127,46 @@ object DesktopLocalFolderSync { allMigrations += syncResult.idMigrations allRemovedBookIds += syncResult.removedBookIds totalStats += syncResult.stats + logDesktopFolderSync( + "folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " + + "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " + + "migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}" + ) var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } - importAnnotationSidecars(root, syncedBooks) - val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks( - books = nextState.rawLibraryBooks, - sourceFolder = folder.uriString + logDesktopFolderSync( + "folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}" ) - if (metadataResult.stats.updatedBooks > 0) { - nextState = nextState.copy(rawLibraryBooks = metadataResult.books) - syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } + importAnnotationSidecars(root, syncedBooks) + if (!metadataOnly) { + val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks( + books = nextState.rawLibraryBooks, + sourceFolder = folder.uriString + ) + if (metadataResult.stats.updatedBooks > 0) { + nextState = nextState.copy(rawLibraryBooks = metadataResult.books) + syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } + } + totalMetadataStats += metadataResult.stats + logDesktopFolderSync( + "folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " + + "updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}" + ) } - totalMetadataStats += metadataResult.stats syncedBooks.forEach { book -> saveBookMetadata(book) - savePdfAnnotationSidecar(book) + if (!metadataOnly) { + savePdfAnnotationSidecar(book) + } } + logDesktopFolderSync( + "folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "savedCandidates=${syncedBooks.size}" + ) } - return DesktopLocalFolderSyncResult( + val result = DesktopLocalFolderSyncResult( state = nextState, shelfRefs = nextShelfRefs, stats = totalStats, @@ -122,6 +175,12 @@ object DesktopLocalFolderSync { removedBookIds = allRemovedBookIds, failedFolders = failedFolders ) + logDesktopFolderSync( + "sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " + + "updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " + + "removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}" + ) + return result } fun saveBookSidecars(book: BookItem) { @@ -130,18 +189,56 @@ object DesktopLocalFolderSync { } fun saveBookMetadata(book: BookItem) { - val metadata = book.toSharedFolderBookMetadata() ?: return - val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return + val metadata = book.toSharedFolderBookMetadata() + if (metadata == null) { + logDesktopFolderSync( + "metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " + + "progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " + + "highlights=${book.readerHighlights.size}" + ) + return + } + val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } + if (root == null) { + logDesktopFolderSync( + "metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + return + } + logDesktopFolderSync( + "metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " + + "progress=${metadata.progressPercentage} recent=${metadata.isRecent} " + + "root=\"${root.absolutePath.folderSyncPreview()}\"" + ) saveMetadataToFolder(root, metadata) } fun savePdfAnnotationSidecar(book: BookItem) { - val path = book.path?.takeIf { it.isNotBlank() } ?: return - if (book.type != FileType.PDF) return - val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return + val path = book.path?.takeIf { it.isNotBlank() } + if (path == null) { + logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}") + return + } + if (book.type != FileType.PDF) { + logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}") + return + } + val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } + if (root == null) { + logDesktopFolderSync( + "annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + return + } val annotationFile = desktopPdfAnnotationFile(path) val bookmarkFile = desktopPdfBookmarkFile(path) val richTextFile = desktopPdfRichTextFile(path) + logDesktopFolderSync( + "annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " + + "pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " + + "hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " + + "localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}" + ) val data = buildMap { if (annotationFile.isFile) { val annotationJson = annotationFile.readText().trim() @@ -175,6 +272,9 @@ object DesktopLocalFolderSync { } } if (data.isEmpty()) { + logDesktopFolderSync( + "annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\"" + ) SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"") return } @@ -188,6 +288,10 @@ object DesktopLocalFolderSync { JsonElement.serializer(), JsonObject(data) ) + logDesktopFolderSync( + "annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " + + "root=\"${root.absolutePath.folderSyncPreview()}\"" + ) if (data.containsKey("text")) { SharedPdfRichTextLog.d( "desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " + @@ -249,35 +353,64 @@ object DesktopLocalFolderSync { private fun readAllMetadata(root: File): Map { val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) - if (!syncDir.isDirectory) return emptyMap() - return syncDir.listFiles().orEmpty() + if (!syncDir.isDirectory) { + logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"") + return emptyMap() + } + var candidates = 0 + var parsed = 0 + var failed = 0 + val result = syncDir.listFiles().orEmpty() .asSequence() - .filter { it.isFile } - .mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } } - .groupBy({ it.first }, { it.second }) - .mapNotNull { (bookId, files) -> - val best = files - .mapNotNull { file -> - runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull() + .filter { it.isFile && it.isMetadataSidecarCandidate() } + .mapNotNull { file -> + candidates++ + runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) } + .onSuccess { parsed++ } + .onFailure { error -> + failed++ + logDesktopFolderSync( + "metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " + + "error=${error.folderSyncSummary()}" + ) } - .filter { it.bookId == bookId } - .maxByOrNull { it.lastModifiedTimestamp } - best?.let { bookId to it } + .getOrNull() } + .groupBy { it.bookId } + .mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } } .toMap() + logDesktopFolderSync( + "metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " + + "candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}" + ) + return result } private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) { val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true) - if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return + if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { + logDesktopFolderSync( + "metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " + + "candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\"" + ) + return + } - val target = File(syncDir, ".${metadata.bookId}.json") - val temp = File(syncDir, ".${metadata.bookId}.tmp") + val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId)) + val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))) runCatching { temp.writeText(metadata.toJsonString()) moveReplacing(temp, target) + logDesktopFolderSync( + "metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " + + "target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}" + ) }.onFailure { + logDesktopFolderSync( + "metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " + + "target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}" + ) runCatching { temp.delete() } } } @@ -287,18 +420,33 @@ object DesktopLocalFolderSync { bookId: String, cleanup: Boolean ): SharedFolderBookMetadata? { + val hashedStem = localFolderSyncSidecarStem(bookId) val candidates = syncDir.listFiles().orEmpty().filter { file -> - val normalized = file.name.removePrefix(".") - file.isFile && ( - normalized == "$bookId.json" || - normalized.startsWith("$bookId.sync-conflict") || - normalized.startsWith("$bookId.json.sync-conflict") - ) + val normalized = file.normalizedSidecarName() + file.isFile && + file.isMetadataSidecarCandidate() && + ( + normalized.matchesJsonSidecarStem(hashedStem) || + normalized.matchesJsonSidecarStem(bookId) + ) } if (candidates.isEmpty()) return null + if (candidates.size > 1) { + logDesktopFolderSync( + "metadata.conflicts book=$bookId candidates=${candidates.size} " + + "dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup" + ) + } val parsed = candidates.mapNotNull { file -> - val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull() + val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) } + .onFailure { error -> + logDesktopFolderSync( + "metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " + + "error=${error.folderSyncSummary()}" + ) + } + .getOrNull() metadata?.takeIf { it.bookId == bookId }?.let { file to it } } val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null @@ -306,10 +454,21 @@ object DesktopLocalFolderSync { if (cleanup) { candidates .filterNot { it == winner.first } - .forEach { runCatching { it.delete() } } - val correctName = ".${bookId}.json" + .forEach { file -> + runCatching { file.delete() } + logDesktopFolderSync( + "metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\"" + ) + } + val correctName = localFolderSyncMetadataFileName(bookId) if (winner.first.name != correctName) { - runCatching { moveReplacing(winner.first, File(syncDir, correctName)) } + val target = File(syncDir, correctName) + runCatching { moveReplacing(winner.first, target) } + .onSuccess { + logDesktopFolderSync( + "metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\"" + ) + } } } @@ -318,30 +477,61 @@ object DesktopLocalFolderSync { private fun preloadAnnotationSidecars(root: File): Map { val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) - if (!syncDir.isDirectory) return emptyMap() - return syncDir.listFiles().orEmpty() + if (!syncDir.isDirectory) { + logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"") + return emptyMap() + } + var candidates = 0 + var parsed = 0 + val result = syncDir.listFiles().orEmpty() .asSequence() - .filter { it.isFile } - .mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } } - .groupBy({ it.first }, { it.second }) - .mapNotNull { (bookId, files) -> - val best = files - .mapNotNull { it.readAnnotationSidecarOrNull() } - .maxByOrNull { it.timestamp } - best?.let { bookId to it } + .filter { it.isFile && it.isAnnotationSidecarCandidate() } + .mapNotNull { file -> + candidates++ + file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull()) + ?.also { parsed++ } } + .groupBy { it.bookId } + .mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } } .toMap() + logDesktopFolderSync( + "annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " + + "candidates=$candidates parsed=$parsed winners=${result.size}" + ) + return result } private fun importAnnotationSidecars(root: File, books: List) { - if (books.isEmpty()) return + if (books.isEmpty()) { + logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"") + return + } val sidecars = preloadAnnotationSidecars(root) - if (sidecars.isEmpty()) return + if (sidecars.isEmpty()) { + logDesktopFolderSync( + "annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}" + ) + return + } books.forEach { book -> - val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach - if (book.type != FileType.PDF) return@forEach - val sidecar = sidecars[book.id] ?: return@forEach + val path = book.path?.takeIf { it.isNotBlank() } + if (path == null) { + logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}") + return@forEach + } + if (book.type != FileType.PDF) { + logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}") + return@forEach + } + val sidecar = sidecars[book.id] + if (sidecar == null) { + logDesktopFolderSync( + "annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " + + "root=\"${root.absolutePath.folderSyncPreview()}\"" + ) + return@forEach + } val annotationFile = desktopPdfAnnotationFile(path) val bookmarkFile = desktopPdfBookmarkFile(path) val richTextFile = desktopPdfRichTextFile(path) @@ -350,7 +540,14 @@ object DesktopLocalFolderSync { bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile() ) + logDesktopFolderSync( + "annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " + + "keys=${sidecar.data.keys.sorted()}" + ) if (sidecar.timestamp <= localTimestamp + 1000L) { + logDesktopFolderSync( + "annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp" + ) if (sidecar.data.containsKey("text") || richTextFile.isFile) { SharedPdfRichTextLog.d( "desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " + @@ -365,11 +562,18 @@ object DesktopLocalFolderSync { annotationFile.parentFile?.mkdirs() annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) annotationFile.setLastModified(sidecar.timestamp) + logDesktopFolderSync( + "annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " + + "file=\"${annotationFile.absolutePath.folderSyncPreview()}\"" + ) } sidecar.data["bookmarks"]?.let { bookmarks -> bookmarkFile.parentFile?.mkdirs() bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks)) bookmarkFile.setLastModified(sidecar.timestamp) + logDesktopFolderSync( + "annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\"" + ) } sidecar.data["text"]?.let { richText -> val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) @@ -381,6 +585,10 @@ object DesktopLocalFolderSync { richTextFile.parentFile?.mkdirs() richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) richTextFile.setLastModified(sidecar.timestamp) + logDesktopFolderSync( + "annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " + + "spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\"" + ) } } } @@ -392,9 +600,20 @@ object DesktopLocalFolderSync { timestamp: Long ) { val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } - val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return + val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() + if (data == null) { + logDesktopFolderSync( + "annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " + + "root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}" + ) + return + } val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true) if (existing != null && existing.timestamp >= timestamp) { + logDesktopFolderSync( + "annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " + + "candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}" + ) if (data.containsKey("text")) { SharedPdfRichTextLog.d( "desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " + @@ -407,15 +626,20 @@ object DesktopLocalFolderSync { val wrapper = JsonObject( mapOf( "version" to JsonPrimitive(1), + "bookId" to JsonPrimitive(bookId), "timestamp" to JsonPrimitive(timestamp), "data" to data ) ) - val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json") - val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp") + val target = File(syncDir, localFolderSyncAnnotationFileName(bookId)) + val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))) runCatching { temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper)) moveReplacing(temp, target) + logDesktopFolderSync( + "annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " + + "target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}" + ) if (data.containsKey("text")) { SharedPdfRichTextLog.d( "desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " + @@ -423,6 +647,10 @@ object DesktopLocalFolderSync { ) } }.onFailure { + logDesktopFolderSync( + "annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " + + "target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}" + ) if (data.containsKey("text")) { SharedPdfRichTextLog.d( "desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " + @@ -438,22 +666,40 @@ object DesktopLocalFolderSync { bookId: String, cleanup: Boolean ): AnnotationSidecar? { - val candidates = syncDir.listFiles().orEmpty().filter { file -> - file.isFile && file.annotationBookIdOrNull() == bookId - } - if (candidates.isEmpty()) return null - val parsed = candidates.mapNotNull { file -> - file.readAnnotationSidecarOrNull()?.let { file to it } + val parsed = syncDir.listFiles().orEmpty() + .filter { file -> file.isFile && file.isAnnotationSidecarCandidate() } + .mapNotNull { file -> + file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull()) + ?.takeIf { it.bookId == bookId } + ?.let { file to it } + } + if (parsed.isEmpty()) return null + if (parsed.size > 1) { + logDesktopFolderSync( + "annotation.conflicts book=$bookId candidates=${parsed.size} " + + "dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup" + ) } val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null if (cleanup) { - candidates + parsed.map { it.first } .filterNot { it == winner.first } - .forEach { runCatching { it.delete() } } - val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json" + .forEach { file -> + runCatching { file.delete() } + logDesktopFolderSync( + "annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\"" + ) + } + val correctName = localFolderSyncAnnotationFileName(bookId) if (winner.first.name != correctName) { - runCatching { moveReplacing(winner.first, File(syncDir, correctName)) } + val target = File(syncDir, correctName) + runCatching { moveReplacing(winner.first, target) } + .onSuccess { + logDesktopFolderSync( + "annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\"" + ) + } } } @@ -462,6 +708,7 @@ object DesktopLocalFolderSync { } private data class AnnotationSidecar( + val bookId: String, val timestamp: Long, val data: JsonObject ) @@ -485,25 +732,23 @@ private fun File.shouldSyncBookFile(): Boolean { return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR } -private fun File.metadataBookIdOrNull(): String? { +private fun File.isMetadataSidecarCandidate(): Boolean { val fileName = name - if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null - if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null - if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null - val normalized = fileName.removePrefix(".") - val base = if (normalized.contains(".sync-conflict")) { - normalized.substringBefore(".sync-conflict") - } else { - normalized.substringBeforeLast(".json") - } - return base.removeSuffix(".json").takeIf { it.isNotBlank() } + if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false + if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false + return fileName.endsWith(".json") || fileName.contains(".sync-conflict") } -private fun File.annotationBookIdOrNull(): String? { +private fun File.isAnnotationSidecarCandidate(): Boolean { + val fileName = name + if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false + if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false + return fileName.endsWith(".json") || fileName.contains(".sync-conflict") +} + +private fun File.legacyAnnotationBookIdOrNull(): String? { var candidate = name - if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null - if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null - if (candidate.contains(".syncthing.")) return null + if (!isAnnotationSidecarCandidate()) return null if (candidate.contains(".sync-conflict")) { candidate = candidate.substringBefore(".sync-conflict") } @@ -511,15 +756,37 @@ private fun File.annotationBookIdOrNull(): String? { if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) { candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length) } - return candidate.removePrefix(".").takeIf { it.isNotBlank() } + val normalized = candidate.removePrefix(".") + if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null + return normalized.takeIf { it.isNotBlank() } } -private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? { +private fun File.normalizedSidecarName(): String { + return name.removePrefix(".") +} + +private fun String.matchesJsonSidecarStem(stem: String): Boolean { + return this == "$stem.json" || + startsWith("$stem.sync-conflict") || + startsWith("$stem.json.sync-conflict") +} + +private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? { return runCatching { val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject + val bookId = root["bookId"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?: fallbackBookId + ?: error("Missing annotation sidecar bookId") val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data") - AnnotationSidecar(timestamp = timestamp, data = data) + AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data) + }.onFailure { error -> + logDesktopFolderSync( + "annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" + ) }.getOrNull() } @@ -547,6 +814,12 @@ private fun File.lastModifiedIfFile(): Long { return if (isFile) lastModified() else 0L } +private fun uniqueFolderSyncTempName(baseName: String): String { + val stem = baseName.removeSuffix(".tmp") + val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}" + return "$stem.$nonce.tmp" +} + private fun String.richSyncPreview(maxLength: Int = 160): String { return replace(Regex("\\s+"), " ") .trim() diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt new file mode 100644 index 0000000..e39304c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsCoverImage.kt @@ -0,0 +1,106 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import com.aryan.reader.shared.opds.OpdsCatalog +import com.aryan.reader.shared.opds.OpdsEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage + +@Composable +internal fun DesktopOpdsCoverImage( + entry: OpdsEntry, + catalog: OpdsCatalog?, + modifier: Modifier = Modifier +) { + val coverUrl = entry.coverUrl?.takeIf { it.isNotBlank() } + val cacheKey = remember(coverUrl, catalog?.id, catalog?.username) { + coverUrl?.let { DesktopOpdsCoverImageCache.cacheKey(it, catalog) } + } + var bitmap by remember(cacheKey) { mutableStateOf(cacheKey?.let { DesktopOpdsCoverImageCache.peek(it) }) } + + LaunchedEffect(cacheKey) { + bitmap = if (coverUrl == null || cacheKey == null) { + null + } else { + withContext(Dispatchers.IO) { + DesktopOpdsCoverImageCache.load(cacheKey, coverUrl, catalog) + } + } + } + + Box( + modifier = modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + val imageBitmap = bitmap + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } else { + Text( + text = entry.title.take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private object DesktopOpdsCoverImageCache { + private const val MaxEntries = 160 + + private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MaxEntries + } + } + + fun cacheKey(url: String, catalog: OpdsCatalog?): String { + return "${catalog?.id.orEmpty()}|${catalog?.username.orEmpty()}|$url" + } + + fun peek(cacheKey: String): ImageBitmap? { + return synchronized(cache) { cache[cacheKey] } + } + + fun load(cacheKey: String, url: String, catalog: OpdsCatalog?): ImageBitmap? { + peek(cacheKey)?.let { return it } + val bitmap = runCatching { + DesktopOpdsHttp.fetchBytes(url, catalog).toImageBitmap() + }.getOrNull() ?: return null + + synchronized(cache) { + cache[cacheKey] = bitmap + } + return bitmap + } + + private fun ByteArray.toImageBitmap(): ImageBitmap? { + return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt index 0fe5410..1e46f0f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt @@ -10,14 +10,15 @@ import com.aryan.reader.shared.opds.SharedOpdsParser import com.aryan.reader.shared.opds.SharedOpdsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.Closeable import java.io.File -import java.net.Authenticator -import java.net.PasswordAuthentication import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.security.MessageDigest import java.time.Duration +import java.util.Base64 import java.util.UUID internal class DesktopOpdsRepository( @@ -144,47 +145,144 @@ internal data class DesktopOpdsStreamResponse( internal object DesktopOpdsHttp { fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse { - val request = request(url).build() - val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofString()) + val response = send(url, username, password, HttpResponse.BodyHandlers.ofString()) return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty()) } fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse { - val request = request(url).build() - val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofInputStream()) + val response = send(url, username, password, HttpResponse.BodyHandlers.ofInputStream()) return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body()) } fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray { - val request = request(url).build() - val response = client(catalog?.username, catalog?.password).send(request, HttpResponse.BodyHandlers.ofByteArray()) + val response = send(url, catalog?.username, catalog?.password, HttpResponse.BodyHandlers.ofByteArray()) if (response.statusCode() !in 200..299) { error("HTTP ${response.statusCode()}") } return response.body() } - private fun request(url: String): HttpRequest.Builder { - return HttpRequest.newBuilder(URI(url.trim())) + private fun send( + url: String, + username: String?, + password: String?, + bodyHandler: HttpResponse.BodyHandler + ): HttpResponse { + ensureNetworkAccess() + val uri = URI(url.trim()) + val response = client().send(request(uri).build(), bodyHandler) + val challenge = response.headers().firstValue("www-authenticate").orElse(null) + val authorization = if (response.statusCode() == 401) { + authorizationHeaderForChallenge( + challenge = challenge, + url = uri.toString(), + username = username, + password = password + ) + } else { + null + } + if (authorization == null) return response + + (response.body() as? Closeable)?.close() + return client().send( + request(uri) + .header("Authorization", authorization) + .build(), + bodyHandler + ) + } + + private fun request(uri: URI): HttpRequest.Builder { + return HttpRequest.newBuilder(uri) .timeout(Duration.ofSeconds(45)) .header("User-Agent", "EpistemeReader/1.0 (Desktop)") } - private fun client(username: String?, password: String?): HttpClient { - val builder = HttpClient.newBuilder() + private fun client(): HttpClient { + return HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(20)) .followRedirects(HttpClient.Redirect.NORMAL) + .build() + } - if (!username.isNullOrBlank() && !password.isNullOrBlank()) { - builder.authenticator( - object : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication { - return PasswordAuthentication(username, password.toCharArray()) + private fun ensureNetworkAccess() { + check(currentDesktopBuildProfile().featurePolicy.networkAccess) { + "Network access is disabled in this desktop build." + } + } + + internal fun authorizationHeaderForChallenge( + challenge: String?, + url: String, + username: String?, + password: String?, + method: String = "GET", + cnonce: String = UUID.randomUUID().toString().replace("-", ""), + nonceCount: String = "00000001" + ): String? { + if (challenge.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) return null + return when { + challenge.startsWith("Basic", ignoreCase = true) -> { + val credentials = "$username:$password".toByteArray(Charsets.ISO_8859_1) + "Basic ${Base64.getEncoder().encodeToString(credentials)}" + } + + challenge.startsWith("Digest", ignoreCase = true) -> { + val params = parseAuthParams(challenge) + val realm = params["realm"].orEmpty() + val nonce = params["nonce"] ?: return null + val qop = params["qop"] + ?.split(',') + ?.map { it.trim().trim('"') } + ?.firstOrNull { it.equals("auth", ignoreCase = true) } + val opaque = params["opaque"] + val uri = URI(url) + val requestUri = buildString { + append(uri.rawPath.takeIf { !it.isNullOrBlank() } ?: "/") + uri.rawQuery?.let { append('?').append(it) } + } + val ha1 = md5("$username:$realm:$password") + val ha2 = md5("${method.uppercase()}:$requestUri") + val responseHash = if (qop != null) { + md5("$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2") + } else { + md5("$ha1:$nonce:$ha2") + } + + buildString { + append("Digest username=\"${username.escapeAuthQuote()}\", ") + append("realm=\"${realm.escapeAuthQuote()}\", ") + append("nonce=\"${nonce.escapeAuthQuote()}\", ") + append("uri=\"${requestUri.escapeAuthQuote()}\", ") + append("response=\"$responseHash\"") + if (qop != null) { + append(", qop=$qop, nc=$nonceCount, cnonce=\"${cnonce.escapeAuthQuote()}\"") + } + if (opaque != null) { + append(", opaque=\"${opaque.escapeAuthQuote()}\"") } } - ) - } + } - return builder.build() + else -> null + } + } + + private fun parseAuthParams(challenge: String): Map { + return Regex("""(\w+)=(?:"([^"]*)"|([^,\s]+))""") + .findAll(challenge) + .associate { match -> + match.groupValues[1].lowercase() to (match.groupValues[2].ifBlank { match.groupValues[3] }) + } + } + + private fun md5(input: String): String { + val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun String.escapeAuthQuote(): String { + return replace("\\", "\\\\").replace("\"", "\\\"") } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt new file mode 100644 index 0000000..f79df97 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt @@ -0,0 +1,30 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderTheme + +internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL +internal val DesktopDefaultPdfVerticalPageGap = 8.dp + +internal fun desktopPdfPageBackgroundColor( + theme: ReaderTheme, + displayMode: PdfDisplayMode +): Color { + return when (theme.id) { + "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White + else -> theme.backgroundColor.takeIf { it.isSpecified } + ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + } +} + +internal fun desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor: Color, + gapBackgroundColor: Color, + isPageGapVisible: Boolean +): Color { + return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt index d657369..c96c0c8 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt @@ -1,5 +1,8 @@ package com.aryan.reader.desktop +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import com.aryan.reader.shared.FileType @@ -30,11 +33,18 @@ data class DesktopPdfDocument( val pageSizes: List, val formatLabel: String = "PDF", val toc: List = emptyList(), - val embeddedAnnotations: List = emptyList() + private val initialEmbeddedAnnotations: List = emptyList() ) { + var embeddedAnnotations: List by mutableStateOf(initialEmbeddedAnnotations) + private set + private val textPageCache = LinkedHashMap() private val searchIndex = SharedPdfSearchIndex(pageCount) + fun replaceEmbeddedAnnotations(annotations: List) { + embeddedAnnotations = annotations + } + fun textPageData(pageIndex: Int): DesktopPdfTextPageData { if (pageIndex !in 0 until pageCount) return DesktopPdfTextPageData() val cached = synchronized(textPageCache) { textPageCache[pageIndex] } @@ -93,7 +103,13 @@ data class DesktopPdfPageRender( data class DesktopPdfMetadata( val title: String? = null, - val author: String? = null + val author: String? = null, + val description: String? = null +) + +internal val DesktopPdfZoomSpec = PdfZoomSpec( + max = 8.0f, + maxRenderPixels = 64_000_000 ) data class DesktopPdfTextChar( @@ -133,10 +149,10 @@ object DesktopPdfium { private val textUrlRegex = Regex("""\b(?:https?://|www\.)[^\s<>"']+""", RegexOption.IGNORE_CASE) private val pdfiumDll: File by lazy(::resolvePdfiumDll) - private val zoomSpec = PdfZoomSpec() + private val zoomSpec = DesktopPdfZoomSpec private val api: PdfiumLibrary by lazy { require(pdfiumDll.exists()) { - "Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll." + missingPdfiumLibraryMessage(pdfiumDll) } Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java) } @@ -205,7 +221,7 @@ object DesktopPdfium { } @Synchronized - fun load(file: File, password: String? = null): DesktopPdfDocument { + fun load(file: File, password: String? = null, loadEmbeddedAnnotations: Boolean = true): DesktopPdfDocument { initLibrary() val startedAt = System.currentTimeMillis() val loadedDocument = loadDocument(file, password) @@ -217,12 +233,13 @@ object DesktopPdfium { val pageCount = api.FPDF_GetPageCount(document) logPdfiumOpen("metadata_loaded pageCount=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") val pageSizes = (0 until pageCount).map { pageIndex -> - loadPage(document, pageIndex).usePointer { page -> - DesktopPdfPageSize( - width = api.FPDF_GetPageWidthF(page), - height = api.FPDF_GetPageHeightF(page) - ) - } + pageSizeByIndex(document, pageIndex) + ?: loadPage(document, pageIndex).usePointer { page -> + DesktopPdfPageSize( + width = api.FPDF_GetPageWidthF(page), + height = api.FPDF_GetPageHeightF(page) + ) + } } logPdfiumOpen("page_sizes_loaded pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") @@ -230,11 +247,17 @@ object DesktopPdfium { logPdfiumOpen("text_index_deferred pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") val toc = extractTableOfContents(document, pageCount) logPdfiumOpen("toc_extracted entries=${toc.size} elapsedMs=${System.currentTimeMillis() - startedAt}") - val embeddedAnnotations = extractEmbeddedAnnotations(document, pageSizes) - logPdfiumOpen( - "embedded_annotations_extracted count=${embeddedAnnotations.size} " + - "elapsedMs=${System.currentTimeMillis() - startedAt}" - ) + val embeddedAnnotations = if (loadEmbeddedAnnotations) { + extractEmbeddedAnnotations(document, pageSizes).also { annotations -> + logPdfiumOpen( + "embedded_annotations_extracted count=${annotations.size} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + } + } else { + logPdfiumOpen("embedded_annotations_deferred elapsedMs=${System.currentTimeMillis() - startedAt}") + emptyList() + } val result = DesktopPdfDocument( path = file.absolutePath, @@ -242,7 +265,7 @@ object DesktopPdfium { pageCount = pageCount, pageSizes = pageSizes, toc = toc, - embeddedAnnotations = embeddedAnnotations + initialEmbeddedAnnotations = embeddedAnnotations ) logPdfiumOpen("open_complete elapsedMs=${System.currentTimeMillis() - startedAt}") return result @@ -296,6 +319,25 @@ object DesktopPdfium { ) } + fun loadEmbeddedAnnotations(document: DesktopPdfDocument): List { + if (synchronized(this) { openComicDocuments.containsKey(document.path) }) return emptyList() + val startedAt = System.currentTimeMillis() + val annotations = mutableListOf() + for ((pageIndex, pageSize) in document.pageSizes.withIndex()) { + val pageAnnotations = synchronized(this) { + if (openComicDocuments.containsKey(document.path)) return annotations + val nativeDocument = openDocuments[document.path]?.pointer ?: return annotations + extractEmbeddedAnnotationsForPage(nativeDocument, pageIndex, pageSize) + } + annotations += pageAnnotations + } + logPdfiumOpen( + "embedded_annotations_loaded_async count=${annotations.size} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return annotations + } + @Synchronized fun extractMetadata(file: File, password: String? = null): DesktopPdfMetadata { initLibrary() @@ -415,8 +457,10 @@ object DesktopPdfium { scale: Float, renderAnnotations: Boolean = true ): DesktopPdfPageRender { + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) openComicDocuments[document.path]?.let { comic -> - val image = comic.renderPageBufferedImage(pageIndex, scale) + val image = comic.renderPageBufferedImage(pageIndex, safeScale) return DesktopPdfPageRender( image = image.toComposeImageBitmap(), width = image.width, @@ -424,8 +468,6 @@ object DesktopPdfium { ) } val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.") - val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1) val stride = width * 4 @@ -459,12 +501,12 @@ object DesktopPdfium { scale: Float, renderAnnotations: Boolean = true ): BufferedImage { - openComicDocuments[document.path]?.let { comic -> - return comic.renderPageBufferedImage(pageIndex, scale) - } - val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.") val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + openComicDocuments[document.path]?.let { comic -> + return comic.renderPageBufferedImage(pageIndex, safeScale) + } + val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.") val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1) val stride = width * 4 @@ -836,7 +878,8 @@ object DesktopPdfium { private fun extractDocumentMetadata(document: Pointer): DesktopPdfMetadata { return DesktopPdfMetadata( title = documentMetaText(document, "Title").cleanPdfMetadata(), - author = documentMetaText(document, "Author").cleanPdfMetadata() + author = documentMetaText(document, "Author").cleanPdfMetadata(), + description = documentMetaText(document, "Subject").cleanPdfMetadata() ) } @@ -900,18 +943,26 @@ object DesktopPdfium { pageSizes: List ): List { return pageSizes.flatMapIndexed { pageIndex, pageSize -> - runCatching { - loadPage(document, pageIndex).usePointer { page -> - val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0) - val rawAnnotations = (0 until count).mapNotNull { index -> - extractEmbeddedAnnotation(page, pageIndex, index, pageSize) - } - SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations) - } - }.getOrDefault(emptyList()) + extractEmbeddedAnnotationsForPage(document, pageIndex, pageSize) } } + private fun extractEmbeddedAnnotationsForPage( + document: Pointer, + pageIndex: Int, + pageSize: DesktopPdfPageSize + ): List { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0) + val rawAnnotations = (0 until count).mapNotNull { index -> + extractEmbeddedAnnotation(page, pageIndex, index, pageSize) + } + SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations) + } + }.getOrDefault(emptyList()) + } + private fun extractEmbeddedAnnotation( page: Pointer, pageIndex: Int, @@ -996,6 +1047,19 @@ object DesktopPdfium { return PointerResource(page, api::FPDF_ClosePage) } + private fun pageSizeByIndex(document: Pointer, pageIndex: Int): DesktopPdfPageSize? { + val width = DoubleArray(1) + val height = DoubleArray(1) + val loaded = runCatching { + api.FPDF_GetPageSizeByIndex(document, pageIndex, width, height) + }.getOrDefault(0) + return if (loaded != 0 && width[0] > 0.0 && height[0] > 0.0) { + DesktopPdfPageSize(width[0].toFloat(), height[0].toFloat()) + } else { + null + } + } + private fun initLibrary() { if (!initialized) { api.FPDF_InitLibrary() @@ -1004,14 +1068,21 @@ object DesktopPdfium { } private fun resolvePdfiumDll(): File { - val overridePath = System.getProperty("reader.pdfium.dll") + val overridePath = System.getProperty("reader.pdfium.path") + ?: System.getenv("READER_PDFIUM_PATH") + ?: System.getProperty("reader.pdfium.dll") ?: System.getenv("READER_PDFIUM_DLL") if (!overridePath.isNullOrBlank()) { return File(overridePath).absoluteFile } - val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll") - .joinToString(File.separator) + val platform = currentDesktopPlatform() + val relativePath = desktopPdfiumRelativePath(platform) + val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty) + ?.takeIf { it.isNotBlank() } + ?.let(::File) + resourceDir?.resolve(relativePath)?.absoluteFile?.takeIf { it.exists() }?.let { return it } + val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } .take(6) .toList() @@ -1022,6 +1093,22 @@ object DesktopPdfium { ?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile } + private fun desktopPdfiumRelativePath(platform: DesktopPlatform): String { + return listOf( + "third_party", + "pdfium", + platform.pdfiumDirectoryName, + platform.pdfiumLibraryDirectoryName, + platform.pdfiumLibraryFileName + ).joinToString(File.separator) + } + + private fun missingPdfiumLibraryMessage(expectedFile: File): String { + val platform = currentDesktopPlatform() + return "Missing Pdfium library for ${platform.os.name.lowercase()}-${platform.architecture.resourceName}. " + + "Expected ${expectedFile.absolutePath}. You can also set reader.pdfium.path or READER_PDFIUM_PATH." + } + private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage { val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN) @@ -1056,11 +1143,11 @@ object DesktopPdfium { } private fun logPdfiumOpen(message: String) { - println("DesktopPdfiumOpen $message") + logDesktopDiagnostic("DesktopPdfiumOpen") { message } } private fun logPdfiumLink(message: String) { - println("DesktopPdfiumLink $message") + logDesktopDiagnostic("DesktopPdfiumLink") { message } } private fun Float.formatLogFloat(): String { @@ -1203,6 +1290,7 @@ object DesktopPdfium { fun FPDF_GetLastError(): Int fun FPDF_GetMetaText(document: Pointer, tag: String, buffer: Pointer?, buflen: Int): Int fun FPDF_GetPageCount(document: Pointer): Int + fun FPDF_GetPageSizeByIndex(document: Pointer, pageIndex: Int, width: DoubleArray, height: DoubleArray): Int fun FPDFBookmark_GetFirstChild(document: Pointer, bookmark: Pointer?): Pointer? fun FPDFBookmark_GetNextSibling(document: Pointer, bookmark: Pointer): Pointer? fun FPDFBookmark_GetTitle(bookmark: Pointer, buffer: Pointer?, buflen: Int): Int diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt new file mode 100644 index 0000000..6cc96bd --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt @@ -0,0 +1,146 @@ +package com.aryan.reader.desktop + +import java.io.File +import java.util.Locale + +internal enum class DesktopOperatingSystem { + WINDOWS, + LINUX, + MACOS, + OTHER +} + +internal enum class DesktopArchitecture(val resourceName: String) { + X64("x64"), + ARM64("arm64"), + X86("x86"), + OTHER("unknown") +} + +internal data class DesktopPlatform( + val os: DesktopOperatingSystem, + val architecture: DesktopArchitecture +) { + val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX + val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS + + val kcefBundleDirectoryName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "kcef-bundle" + DesktopOperatingSystem.LINUX -> "kcef-bundle-linux-${architecture.resourceName}" + DesktopOperatingSystem.MACOS -> "kcef-bundle-macos-${architecture.resourceName}" + DesktopOperatingSystem.OTHER -> "kcef-bundle-${architecture.resourceName}" + } + + val pdfiumDirectoryName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8" + DesktopOperatingSystem.LINUX -> "linux-${architecture.resourceName}-v8" + DesktopOperatingSystem.MACOS -> "mac-${architecture.resourceName}-v8" + DesktopOperatingSystem.OTHER -> "${architecture.resourceName}-v8" + } + + val pdfiumLibraryFileName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "pdfium.dll" + DesktopOperatingSystem.LINUX -> "libpdfium.so" + DesktopOperatingSystem.MACOS -> "libpdfium.dylib" + DesktopOperatingSystem.OTHER -> "pdfium" + } + + val pdfiumLibraryDirectoryName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "bin" + DesktopOperatingSystem.LINUX, + DesktopOperatingSystem.MACOS, + DesktopOperatingSystem.OTHER -> "lib" + } +} + +internal fun currentDesktopPlatform( + osName: String = System.getProperty("os.name").orEmpty(), + osArch: String = System.getProperty("os.arch").orEmpty() +): DesktopPlatform { + return DesktopPlatform( + os = desktopOperatingSystem(osName), + architecture = desktopArchitecture(osArch) + ) +} + +internal fun desktopOperatingSystem(osName: String): DesktopOperatingSystem { + val normalized = osName.trim().lowercase(Locale.ROOT) + return when { + normalized.startsWith("windows") -> DesktopOperatingSystem.WINDOWS + normalized == "linux" || normalized.contains("linux") -> DesktopOperatingSystem.LINUX + normalized.startsWith("mac") || normalized.contains("darwin") -> DesktopOperatingSystem.MACOS + else -> DesktopOperatingSystem.OTHER + } +} + +internal fun desktopArchitecture(osArch: String): DesktopArchitecture { + return when (osArch.trim().lowercase(Locale.ROOT)) { + "amd64", "x86_64", "x64" -> DesktopArchitecture.X64 + "aarch64", "arm64" -> DesktopArchitecture.ARM64 + "x86", "i386", "i686" -> DesktopArchitecture.X86 + else -> DesktopArchitecture.OTHER + } +} + +internal fun desktopUserDataRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_DATA_HOME", ".local/share", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") + } +} + +internal fun desktopUserConfigRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CONFIG_HOME", ".config", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") + } +} + +internal fun desktopUserCacheRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CACHE_HOME", ".cache", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Caches/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme/cache") + } +} + +private fun windowsRoamingBase(env: (String) -> String?, userHome: String): File { + return env("APPDATA") + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?: File(userHome, "AppData/Roaming") +} + +private fun xdgBase( + envName: String, + fallbackRelativePath: String, + env: (String) -> String?, + userHome: String +): File { + return env(envName) + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?.takeIf { it.isAbsolute } + ?: File(userHome, fallbackRelativePath) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt new file mode 100644 index 0000000..3e15c05 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopStartupSplash.kt @@ -0,0 +1,163 @@ +package com.aryan.reader.desktop + +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import java.awt.EventQueue +import java.awt.Font +import java.awt.GraphicsEnvironment +import java.awt.Image +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.atomic.AtomicReference +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.ImageIcon +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JProgressBar +import javax.swing.JWindow +import javax.swing.SwingConstants + +internal data class DesktopStartupSplashSpec( + val title: String = EpistemeDesktopWindowTitle, + val message: String = "Opening your library", + val width: Int = 360, + val height: Int = 220 +) + +internal fun epistemeDesktopStartupSplashSpec( + profile: DesktopBuildProfile = currentDesktopBuildProfile() +): DesktopStartupSplashSpec { + return DesktopStartupSplashSpec(title = profile.appName) +} + +internal class DesktopStartupSplash private constructor( + private val window: JWindow +) { + fun close() { + runOnSplashEventThread { + window.isVisible = false + window.dispose() + } + } + + companion object { + fun show(spec: DesktopStartupSplashSpec = epistemeDesktopStartupSplashSpec()): DesktopStartupSplash? { + if (GraphicsEnvironment.isHeadless()) return null + + val splashRef = AtomicReference() + runOnSplashEventThreadAndWait { + runCatching { + val window = JWindow().apply { + name = "episteme-startup-splash" + preferredSize = Dimension(spec.width, spec.height) + minimumSize = Dimension(spec.width, spec.height) + background = SplashBackground + contentPane = startupSplashContent(spec) + pack() + setLocationRelativeTo(null) + isAlwaysOnTop = true + isVisible = true + } + splashRef.set(DesktopStartupSplash(window)) + } + } + return splashRef.get() + } + } +} + +private fun startupSplashContent(spec: DesktopStartupSplashSpec): JPanel { + return JPanel(BorderLayout()).apply { + preferredSize = Dimension(spec.width, spec.height) + background = SplashBackground + border = BorderFactory.createLineBorder(SplashBorder) + + val body = JPanel().apply { + background = SplashBackground + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = BorderFactory.createEmptyBorder(24, 28, 22, 28) + } + + startupSplashIcon()?.let { icon -> + body.add( + JLabel(icon).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + } + ) + body.add(Box.createVerticalStrut(14)) + } + + body.add( + JLabel(spec.title).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + foreground = SplashTitle + font = font.deriveFont(Font.BOLD, 24f) + } + ) + body.add(Box.createVerticalStrut(8)) + body.add( + JLabel(spec.message).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + foreground = SplashText + font = font.deriveFont(Font.PLAIN, 13f) + } + ) + body.add(Box.createVerticalStrut(20)) + body.add( + JProgressBar().apply { + alignmentX = Component.CENTER_ALIGNMENT + isIndeterminate = true + isBorderPainted = false + preferredSize = Dimension(220, 8) + maximumSize = Dimension(220, 8) + foreground = SplashAccent + background = SplashTrack + } + ) + + add(body, BorderLayout.CENTER) + } +} + +private fun startupSplashIcon(): ImageIcon? { + val resource = Thread.currentThread().contextClassLoader?.getResource(EpistemeDesktopWindowIconResource) + ?: DesktopStartupSplash::class.java.classLoader?.getResource(EpistemeDesktopWindowIconResource) + ?: return null + val icon = ImageIcon(resource) + return ImageIcon(icon.image.getScaledInstance(56, 56, Image.SCALE_SMOOTH)) +} + +private fun runOnSplashEventThread(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + } else { + EventQueue.invokeLater { block() } + } +} + +private fun runOnSplashEventThreadAndWait(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + return + } + try { + EventQueue.invokeAndWait { block() } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } catch (_: InvocationTargetException) { + // Startup feedback should never prevent the app from launching. + } +} + +private val SplashBackground = Color(0xF9, 0xF7, 0xEF) +private val SplashBorder = Color(0xD8, 0xD2, 0xC3) +private val SplashTitle = Color(0x1E, 0x22, 0x1A) +private val SplashText = Color(0x61, 0x64, 0x58) +private val SplashAccent = Color(0x2F, 0x6F, 0x68) +private val SplashTrack = Color(0xE3, 0xDE, 0xD1) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt index baeb4a4..26f63ce 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt @@ -3,7 +3,7 @@ package com.aryan.reader.desktop private const val DesktopTtsLogTag = "EpistemeDesktopTts" internal fun logDesktopTts(message: String) { - println("$DesktopTtsLogTag $message") + logDesktopDiagnostic(DesktopTtsLogTag) { message } } internal fun Throwable.desktopTtsSummary(): String { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt new file mode 100644 index 0000000..a949aa0 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowPolish.kt @@ -0,0 +1,291 @@ +package com.aryan.reader.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.EventQueue +import java.awt.Color as AwtColor +import java.awt.Window as AwtWindow +import java.util.concurrent.atomic.AtomicReference +import javax.swing.RootPaneContainer +import javax.swing.SwingUtilities +import kotlinx.coroutines.delay + +internal const val EpistemeDesktopWindowTitle = EpistemeDesktopStandardAppName +internal const val EpistemeDesktopWindowIconResource = "episteme_icon.png" +internal const val EpistemeDesktopWindowMinimumWidthPx = 960 +internal const val EpistemeDesktopWindowMinimumHeightPx = 640 + +internal data class DesktopWindowDefaults( + val title: String, + val defaultSize: DpSize, + val minimumSize: Dimension, + val iconResourcePath: String +) + +internal fun epistemeDesktopWindowDefaults( + profile: DesktopBuildProfile = currentDesktopBuildProfile() +): DesktopWindowDefaults { + return DesktopWindowDefaults( + title = profile.appName, + defaultSize = DpSize(1280.dp, 820.dp), + minimumSize = Dimension(EpistemeDesktopWindowMinimumWidthPx, EpistemeDesktopWindowMinimumHeightPx), + iconResourcePath = EpistemeDesktopWindowIconResource + ) +} + +internal data class DesktopWindowChromeColors( + val useDarkMode: Boolean, + val captionColorRef: Int, + val textColorRef: Int, + val borderColorRef: Int +) + +internal fun desktopWindowChromeColors( + captionColor: Color, + textColor: Color, + borderColor: Color +): DesktopWindowChromeColors { + return DesktopWindowChromeColors( + useDarkMode = captionColor.luminance() < 0.5f, + captionColorRef = captionColor.toWindowsColorRef(), + textColorRef = textColor.toWindowsColorRef(), + borderColorRef = borderColor.toWindowsColorRef() + ) +} + +@Composable +internal fun EpistemeDesktopWindowChromeEffect( + window: Component?, + captionColor: Color, + textColor: Color, + borderColor: Color +) { + DisposableEffect(window, captionColor, textColor, borderColor) { + applyDesktopWindowBackground(window, borderColor) + applyWindowsDesktopWindowChrome( + window = window, + colors = desktopWindowChromeColors( + captionColor = captionColor, + textColor = textColor, + borderColor = borderColor + ) + ) + onDispose {} + } +} + +@Composable +internal fun EpistemeDesktopWindowDecorationEffect( + window: Component?, + hideDecoration: Boolean +) { + val originalStyle = remember(window) { AtomicReference(null) } + LaunchedEffect(window, hideDecoration) { + delay(if (hideDecoration) 120L else 80L) + applyWindowsDesktopWindowDecoration( + window = window, + hideDecoration = hideDecoration, + originalStyle = originalStyle + ) + } + DisposableEffect(window, hideDecoration) { + onDispose { + if (hideDecoration) { + applyWindowsDesktopWindowDecoration( + window = window, + hideDecoration = false, + originalStyle = originalStyle + ) + } + } + } +} + +internal fun isWindowsDesktop(osName: String = System.getProperty("os.name").orEmpty()): Boolean { + return osName.startsWith("Windows", ignoreCase = true) +} + +private fun applyDesktopWindowBackground(window: Component?, color: Color) { + val awtColor = color.toAwtOpaqueColor() + runOnEventDispatchThread { + val awtWindow = window.toAwtWindowOrNull() + window?.background = awtColor + awtWindow?.background = awtColor + (awtWindow as? Container)?.background = awtColor + (awtWindow as? RootPaneContainer)?.let { rootPaneContainer -> + rootPaneContainer.contentPane.background = awtColor + rootPaneContainer.rootPane.background = awtColor + rootPaneContainer.layeredPane.background = awtColor + rootPaneContainer.glassPane.background = awtColor + } + } +} + +private fun applyWindowsDesktopWindowChrome( + window: Component?, + colors: DesktopWindowChromeColors, + osName: String = System.getProperty("os.name").orEmpty() +) { + if (!isWindowsDesktop(osName)) return + runOnEventDispatchThread { + val awtWindow = window.toAwtWindowOrNull() ?: return@runOnEventDispatchThread + val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@runOnEventDispatchThread + WindowsDwmApi.applyWindowChrome(hwnd, colors) + } +} + +private fun applyWindowsDesktopWindowDecoration( + window: Component?, + hideDecoration: Boolean, + originalStyle: AtomicReference, + osName: String = System.getProperty("os.name").orEmpty() +) { + if (!isWindowsDesktop(osName)) return + EventQueue.invokeLater decoration@{ + val awtWindow = window.toAwtWindowOrNull() ?: return@decoration + val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@decoration + val api = runCatching { User32Api.INSTANCE }.getOrNull() ?: return@decoration + if (hideDecoration) { + val style = api.GetWindowLongW(hwnd, GWL_STYLE) + originalStyle.compareAndSet(null, style) + val fullscreenStyle = style and WS_CAPTION.inv() and WS_THICKFRAME.inv() + if (fullscreenStyle != style) { + api.SetWindowLongW(hwnd, GWL_STYLE, fullscreenStyle) + api.SetWindowPos( + hwnd, + null, + 0, + 0, + 0, + 0, + SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED + ) + } + } else { + val restoredStyle = originalStyle.getAndSet(null) ?: return@decoration + api.SetWindowLongW(hwnd, GWL_STYLE, restoredStyle) + api.SetWindowPos( + hwnd, + null, + 0, + 0, + 0, + 0, + SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED + ) + } + } +} + +private fun Component?.toAwtWindowOrNull(): AwtWindow? { + return when (this) { + null -> null + is AwtWindow -> this + else -> SwingUtilities.getWindowAncestor(this) + } +} + +private fun runOnEventDispatchThread(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + } else { + EventQueue.invokeLater(block) + } +} + +private fun Color.toAwtOpaqueColor(): AwtColor { + val argb = toArgb() + return AwtColor( + (argb shr 16) and 0xFF, + (argb shr 8) and 0xFF, + argb and 0xFF + ) +} + +private fun Color.toWindowsColorRef(): Int { + val argb = toArgb() + val red = (argb shr 16) and 0xFF + val green = (argb shr 8) and 0xFF + val blue = argb and 0xFF + return red or (green shl 8) or (blue shl 16) +} + +private const val GWL_STYLE = -16 +private const val WS_CAPTION = 0x00C00000 +private const val WS_THICKFRAME = 0x00040000 +private const val SWP_NOSIZE = 0x0001 +private const val SWP_NOMOVE = 0x0002 +private const val SWP_NOZORDER = 0x0004 +private const val SWP_NOACTIVATE = 0x0010 +private const val SWP_FRAMECHANGED = 0x0020 + +private object WindowsDwmApi { + private const val DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + private const val DWMWA_BORDER_COLOR = 34 + private const val DWMWA_CAPTION_COLOR = 35 + private const val DWMWA_TEXT_COLOR = 36 + + fun applyWindowChrome(hwnd: Pointer, colors: DesktopWindowChromeColors) { + val api = runCatching { DwmApi.INSTANCE }.getOrNull() ?: return + val darkModeValue = if (colors.useDarkMode) 1 else 0 + val darkModeResult = api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, darkModeValue) + if (darkModeResult != 0) { + api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, darkModeValue) + } + api.setIntAttribute(hwnd, DWMWA_CAPTION_COLOR, colors.captionColorRef) + api.setIntAttribute(hwnd, DWMWA_TEXT_COLOR, colors.textColorRef) + api.setIntAttribute(hwnd, DWMWA_BORDER_COLOR, colors.borderColorRef) + } + + private fun DwmApi.setIntAttribute(hwnd: Pointer, attribute: Int, value: Int): Int { + return runCatching { + val ref = IntByReference(value) + DwmSetWindowAttribute(hwnd, attribute, ref.pointer, Int.SIZE_BYTES) + }.getOrDefault(-1) + } +} + +private interface DwmApi : StdCallLibrary { + fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: Pointer, valueSize: Int): Int + + companion object { + val INSTANCE: DwmApi by lazy { + Native.load("dwmapi", DwmApi::class.java) as DwmApi + } + } +} + +private interface User32Api : StdCallLibrary { + fun GetWindowLongW(hwnd: Pointer, index: Int): Int + fun SetWindowLongW(hwnd: Pointer, index: Int, value: Int): Int + fun SetWindowPos( + hwnd: Pointer, + insertAfter: Pointer?, + x: Int, + y: Int, + cx: Int, + cy: Int, + flags: Int + ): Boolean + + companion object { + val INSTANCE: User32Api by lazy { + Native.load("user32", User32Api::class.java) as User32Api + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt new file mode 100644 index 0000000..fc79d31 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt @@ -0,0 +1,155 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private const val DesktopWindowStateSchemaVersion = 1 + +internal enum class DesktopSavedWindowPlacement { + FLOATING, + MAXIMIZED, + FULLSCREEN +} + +internal data class DesktopWindowStateSnapshot( + val placement: DesktopSavedWindowPlacement, + val widthDp: Float, + val heightDp: Float, + val xDp: Float? = null, + val yDp: Float? = null +) { + fun toWindowPlacement(): WindowPlacement { + return when (placement) { + DesktopSavedWindowPlacement.FLOATING -> WindowPlacement.Floating + DesktopSavedWindowPlacement.MAXIMIZED -> WindowPlacement.Maximized + DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Fullscreen + } + } + + fun toWindowSize(defaultSize: DpSize): DpSize { + val width = widthDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumWidthPx.toFloat() } + val height = heightDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumHeightPx.toFloat() } + return DpSize( + width = width?.dp ?: defaultSize.width, + height = height?.dp ?: defaultSize.height + ) + } + + fun toWindowPosition(): WindowPosition { + val x = xDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault + val y = yDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault + return WindowPosition(x.dp, y.dp) + } + + fun sanitized(): DesktopWindowStateSnapshot { + return copy( + widthDp = widthDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumWidthPx.toFloat()) + ?: EpistemeDesktopWindowMinimumWidthPx.toFloat(), + heightDp = heightDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumHeightPx.toFloat()) + ?: EpistemeDesktopWindowMinimumHeightPx.toFloat(), + xDp = xDp?.takeIf { it.isFinite() }, + yDp = yDp?.takeIf { it.isFinite() } + ) + } + + fun toJsonObject(): JsonObject { + val sanitized = sanitized() + return JsonObject( + buildMap { + put("schemaVersion", JsonPrimitive(DesktopWindowStateSchemaVersion)) + put("placement", JsonPrimitive(sanitized.placement.name)) + put("widthDp", JsonPrimitive(sanitized.widthDp)) + put("heightDp", JsonPrimitive(sanitized.heightDp)) + put("xDp", sanitized.xDp?.let { JsonPrimitive(it) } ?: JsonNull) + put("yDp", sanitized.yDp?.let { JsonPrimitive(it) } ?: JsonNull) + } + ) + } + + companion object { + fun default(): DesktopWindowStateSnapshot { + return DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.MAXIMIZED, + widthDp = 1280f, + heightDp = 820f + ) + } + + fun fromWindowState(state: WindowState): DesktopWindowStateSnapshot? { + if (state.isMinimized) return null + val size = state.size + val width = size.width.value.takeIf { it.isFinite() } ?: return null + val height = size.height.value.takeIf { it.isFinite() } ?: return null + val placement = when (state.placement) { + WindowPlacement.Floating -> DesktopSavedWindowPlacement.FLOATING + WindowPlacement.Maximized -> DesktopSavedWindowPlacement.MAXIMIZED + WindowPlacement.Fullscreen -> DesktopSavedWindowPlacement.FULLSCREEN + } + val position = state.position.takeIf { it.isSpecified } + return DesktopWindowStateSnapshot( + placement = placement, + widthDp = width, + heightDp = height, + xDp = position?.x?.value?.takeIf { it.isFinite() }, + yDp = position?.y?.value?.takeIf { it.isFinite() } + ).sanitized() + } + + fun fromJsonElement(element: JsonElement): DesktopWindowStateSnapshot? { + val obj = runCatching { element.jsonObject }.getOrNull() ?: return null + val placement = obj["placement"] + ?.jsonPrimitive + ?.content + ?.let { runCatching { DesktopSavedWindowPlacement.valueOf(it) }.getOrNull() } + ?: DesktopSavedWindowPlacement.MAXIMIZED + val width = obj["widthDp"]?.jsonPrimitive?.floatOrNull ?: return null + val height = obj["heightDp"]?.jsonPrimitive?.floatOrNull ?: return null + return DesktopWindowStateSnapshot( + placement = placement, + widthDp = width, + heightDp = height, + xDp = obj["xDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull, + yDp = obj["yDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull + ).sanitized() + } + } +} + +internal class DesktopWindowStateStore( + private val stateFile: File = defaultWindowStateFile() +) { + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + fun load(): DesktopWindowStateSnapshot? { + if (!stateFile.exists()) return null + return runCatching { + DesktopWindowStateSnapshot.fromJsonElement(json.parseToJsonElement(stateFile.readText())) + }.getOrNull() + } + + fun save(snapshot: DesktopWindowStateSnapshot) { + stateFile.parentFile?.mkdirs() + stateFile.writeText(json.encodeToString(JsonElement.serializer(), snapshot.toJsonObject())) + } + + companion object { + fun defaultWindowStateFile(): File { + return File(desktopUserConfigRoot(), "window_state.json") + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt new file mode 100644 index 0000000..d03d1f1 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Launcher.kt @@ -0,0 +1,6 @@ +package com.aryan.reader.desktop + +fun main() { + val startupSplash = DesktopStartupSplash.show() + launchEpistemeDesktopApplication(startupSplash) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt index a2edc2b..7869128 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt @@ -1,18 +1,33 @@ package com.aryan.reader.desktop +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -20,9 +35,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -31,9 +48,20 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.NavigateBefore import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.ZoomIn import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.AlertDialog @@ -47,27 +75,34 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter @@ -75,8 +110,15 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -84,30 +126,45 @@ import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.platform.Font as DesktopFont +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import com.aryan.reader.paginatedreader.SemanticBlock import com.aryan.reader.paginatedreader.SemanticFlexContainer import com.aryan.reader.paginatedreader.SemanticHeader @@ -121,6 +178,8 @@ import com.aryan.reader.paginatedreader.SemanticTable import com.aryan.reader.paginatedreader.SemanticTextBlock import com.aryan.reader.paginatedreader.SemanticWrappingBlock import com.aryan.reader.shared.AppAction +import com.aryan.reader.shared.AppContrastOption +import com.aryan.reader.shared.AppThemeMode import com.aryan.reader.shared.BannerMessage import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.BookShelfRef @@ -131,6 +190,7 @@ import com.aryan.reader.shared.FileType import com.aryan.reader.shared.ImportedBookFile import com.aryan.reader.shared.LibraryAction import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.PdfTocEntry import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID import com.aryan.reader.shared.ReaderAiByokSettings @@ -152,6 +212,7 @@ import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.ReaderTexture import com.aryan.reader.shared.ReaderTextureFilePrefix import com.aryan.reader.shared.ReaderTheme +import com.aryan.reader.shared.ReaderTtsCacheSummary import com.aryan.reader.shared.ReaderToolbarPreferences import com.aryan.reader.shared.ReaderTtsChunk import com.aryan.reader.shared.ReaderTtsPlanner @@ -160,12 +221,19 @@ import com.aryan.reader.shared.ReaderTtsReadScope import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.SearchHighlightMode import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.SharedFeaturePolicy import com.aryan.reader.shared.SharedFolderPathResolver +import com.aryan.reader.shared.SharedImportOutcomeCounts +import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.SharedLibraryProjectionInput import com.aryan.reader.shared.SharedLibrarySnapshot import com.aryan.reader.shared.SharedLibraryStateProjector import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.SharedSettingsAction +import com.aryan.reader.shared.SharedSettingsDestination +import com.aryan.reader.shared.SharedSettingsHubInput +import com.aryan.reader.shared.SharedSettingsPlatform import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfRecord import com.aryan.reader.shared.ShelfType @@ -173,11 +241,11 @@ import com.aryan.reader.shared.SmartCollectionDefinition import com.aryan.reader.shared.SmartField import com.aryan.reader.shared.SmartOperator import com.aryan.reader.shared.SmartRule -import com.aryan.reader.shared.SyncedFolder import com.aryan.reader.shared.Tag import com.aryan.reader.shared.UserHighlight import com.aryan.reader.shared.externalLookupUrl import com.aryan.reader.shared.maskedReaderAiKey +import com.aryan.reader.shared.sharedSettingsHubModel import com.aryan.reader.shared.withTtsReplacements import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.PdfInkTool @@ -190,13 +258,16 @@ import com.aryan.reader.shared.pdf.PdfVisiblePageLayout import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import com.aryan.reader.shared.pdf.SharedPdfInkRenderer import com.aryan.reader.shared.pdf.SharedPdfJumpHistory import com.aryan.reader.shared.pdf.SharedPdfReaderAction import com.aryan.reader.shared.pdf.SharedPdfReaderState +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport import com.aryan.reader.shared.pdf.SharedPdfRichDocument import com.aryan.reader.shared.pdf.SharedPdfRichTextController import com.aryan.reader.shared.pdf.SharedPdfRichTextLog @@ -208,6 +279,7 @@ import com.aryan.reader.shared.pdf.SharedPdfTextDraft import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig import com.aryan.reader.shared.pdf.mostVisiblePdfPageIndex +import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp import com.aryan.reader.shared.pdf.reduce import com.aryan.reader.shared.pdf.sharedPdfTextStyle import com.aryan.reader.shared.pdf.sharedPdfStrokePercent @@ -219,12 +291,22 @@ import com.aryan.reader.shared.pdf.withSharedPdfTextStyle import com.aryan.reader.shared.pdf.withStyle import com.aryan.reader.shared.pdf.withText import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.ReaderLayoutSignature import com.aryan.reader.shared.reader.ReaderLinkTarget +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.reader.SampleReaderBooks +import com.aryan.reader.shared.reader.SharedEpubBook +import com.aryan.reader.shared.reader.SharedEpubChapter +import com.aryan.reader.shared.reader.SharedEpubPaginationCache +import com.aryan.reader.shared.reader.SharedEpubMetadataEditor +import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate import com.aryan.reader.shared.reader.SharedReaderTextAlign import com.aryan.reader.shared.reader.SharedJvmBookLoader +import com.aryan.reader.shared.reader.ReaderViewportSpec +import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator +import com.aryan.reader.shared.reader.layoutSignature import com.aryan.reader.shared.opds.OpdsAcquisition import com.aryan.reader.shared.opds.OpdsCatalog import com.aryan.reader.shared.opds.OpdsEntry @@ -233,15 +315,21 @@ import com.aryan.reader.shared.opds.SharedOpdsController import com.aryan.reader.shared.opds.SharedOpdsDownloadState import com.aryan.reader.shared.opds.SharedOpdsStreamUri import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.ui.DesktopEpubNativeImage import com.aryan.reader.shared.ui.NonReaderLibraryTab import com.aryan.reader.shared.ui.ReaderContentNavigationTarget +import com.aryan.reader.shared.ui.ReaderContentRenderPlan +import com.aryan.reader.shared.ui.ReaderMinimalSlider +import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick +import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction +import com.aryan.reader.shared.ui.SharedNativePaginatedReader import com.aryan.reader.shared.ui.ReaderWorkspaceShell import com.aryan.reader.shared.ui.SharedAddToShelfDialog import com.aryan.reader.shared.ui.SharedAppShell import com.aryan.reader.shared.ui.SharedAppTab import com.aryan.reader.shared.ui.SharedAppTheme +import com.aryan.reader.shared.ui.SharedAppThemeSettingsDialog import com.aryan.reader.shared.ui.SharedAboutScreen -import com.aryan.reader.shared.ui.SharedBookEditDialog import com.aryan.reader.shared.ui.SharedBookInfoDialog import com.aryan.reader.shared.ui.SharedConfirmDialog import com.aryan.reader.shared.ui.SharedCustomFontsScreen @@ -250,9 +338,12 @@ import com.aryan.reader.shared.ui.SharedHomeScreen import com.aryan.reader.shared.ui.SharedLibraryScreen import com.aryan.reader.shared.ui.SharedMarkdownText import com.aryan.reader.shared.ui.SharedOpdsScreen +import com.aryan.reader.shared.ui.SharedSettingsHub import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor +import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput @@ -260,17 +351,22 @@ import com.aryan.reader.shared.ui.SharedPdfRichTextLayer import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay import com.aryan.reader.shared.ui.SharedPdfTextStyleControls +import com.aryan.reader.shared.ui.SharedReaderPopupLayer import com.aryan.reader.shared.ui.SharedReaderScreen +import com.aryan.reader.shared.ui.SharedStableOutlinedTextField import com.aryan.reader.shared.ui.SharedReaderThemeControls import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls +import com.aryan.reader.shared.ui.SharedPdfVerticalScrollbar +import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar import com.aryan.reader.shared.ui.SharedShelvesScreen import com.aryan.reader.shared.ui.SharedSupportProjectScreen import com.aryan.reader.shared.ui.SharedTextInputDialog import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel +import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll +import com.aryan.reader.shared.ui.sharedReaderPopupWidth import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest import com.aryan.reader.shared.ui.sharedPdfHitTest import com.aryan.reader.shared.ui.toSharedPdfPoint -import com.aryan.reader.shared.withImportedFiles import com.multiplatform.webview.jsbridge.IJsMessageHandler import com.multiplatform.webview.jsbridge.JsMessage import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge @@ -278,14 +374,16 @@ import com.multiplatform.webview.request.RequestInterceptor import com.multiplatform.webview.request.WebRequest import com.multiplatform.webview.request.WebRequestInterceptResult import com.multiplatform.webview.web.LoadingState +import com.multiplatform.webview.web.WebContent import com.multiplatform.webview.web.WebView import com.multiplatform.webview.web.WebViewNavigator +import com.multiplatform.webview.web.WebViewState import com.multiplatform.webview.web.rememberWebViewNavigator -import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData import dev.datlag.kcef.KCEF import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -301,7 +399,12 @@ import java.awt.Container import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame +import java.awt.GraphicsDevice import java.awt.Component +import java.awt.KeyEventDispatcher +import java.awt.KeyboardFocusManager +import java.awt.Rectangle +import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants import java.awt.dnd.DropTarget @@ -309,6 +412,7 @@ import java.awt.dnd.DropTargetAdapter import java.awt.dnd.DropTargetDragEvent import java.awt.dnd.DropTargetEvent import java.awt.dnd.DropTargetDropEvent +import java.awt.event.KeyEvent as AwtKeyEvent import java.io.ByteArrayInputStream import java.io.File import java.net.URI @@ -319,27 +423,202 @@ import java.util.Locale import java.util.UUID import java.util.concurrent.atomic.AtomicReference import javax.imageio.ImageIO -import javax.swing.JOptionPane -import javax.swing.SwingUtilities import javax.swing.JFileChooser import kotlin.math.abs +import kotlin.math.exp import kotlin.math.max import kotlin.math.roundToInt +private data class DesktopReaderOpening( + val requestId: Long, + val bookId: String, + val title: String, + val formatLabel: String, + val returnTab: SharedAppTab +) + +private sealed interface DesktopReaderOpenResult { + val opening: DesktopReaderOpening + val book: BookItem + + data class Pdf( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val document: DesktopPdfDocument + ) : DesktopReaderOpenResult + + data class Text( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val session: ReaderSessionState + ) : DesktopReaderOpenResult + + data class Failure( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val message: String + ) : DesktopReaderOpenResult +} + fun main() { + launchEpistemeDesktopApplication() +} + +private fun desktopEmptyReaderBook(): SharedEpubBook { + return SharedEpubBook( + id = "desktop_empty_reader", + fileName = "", + title = "No book open", + chapters = listOf( + SharedEpubChapter( + id = "empty", + title = "No book open", + plainText = "" + ) + ) + ) +} + +private fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { + return if (appSeedColor == null) { + copy(appSeedColor = DesktopDefaultAppSeedColor) + } else { + this + } +} + +internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { configureComposeSwingInterop() application { + val windowDefaults = remember { epistemeDesktopWindowDefaults() } + val windowStateStore = remember { DesktopWindowStateStore() } + val restoredWindowState = remember { windowStateStore.load() } + val windowState = rememberWindowState( + placement = restoredWindowState?.toWindowPlacement() + ?: DesktopWindowStateSnapshot.default().toWindowPlacement(), + position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center), + size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize + ) + var readerFullscreen by remember { mutableStateOf(false) } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = windowStateStore, + enabled = !readerFullscreen + ) Window( onCloseRequest = ::exitApplication, - title = "Episteme", + title = windowDefaults.title, + state = windowState, + icon = painterResource(windowDefaults.iconResourcePath) ) { - EpistemeDesktopApp(window) + DisposableEffect(window, windowDefaults.minimumSize) { + window.minimumSize = windowDefaults.minimumSize + onDispose { + startupSplash?.close() + } + } + EpistemeDesktopStartupGate( + window = window, + startupSplash = startupSplash, + appWindowPlacement = windowState.placement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = { readerFullscreen = it } + ) + } + } +} + +@Composable +private fun EpistemeDesktopStartupGate( + window: Component?, + startupSplash: DesktopStartupSplash?, + appWindowPlacement: WindowPlacement, + readerFullscreen: Boolean, + onReaderFullscreenChange: (Boolean) -> Unit +) { + var showApp by remember { mutableStateOf(false) } + + DisposableEffect(startupSplash) { + onDispose { + startupSplash?.close() + } + } + + if (showApp) { + EpistemeDesktopApp( + window = window, + appWindowPlacement = appWindowPlacement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = onReaderFullscreenChange + ) + } else { + EpistemeDesktopStartupScreen(window = window) + } + + LaunchedEffect(Unit) { + withFrameNanos { } + startupSplash?.close() + delay(80L) + showApp = true + } +} + +@Composable +private fun EpistemeDesktopStartupScreen(window: Component?) { + val appTitle = remember { epistemeDesktopWindowDefaults().title } + SharedAppTheme( + appThemeMode = AppThemeMode.SYSTEM, + appContrastOption = AppContrastOption.STANDARD, + appTextDimFactorLight = 1.0f, + appTextDimFactorDark = 1.0f, + appSeedColor = DesktopDefaultAppSeedColor + ) { + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(32.dp) + ) { + Image( + painter = painterResource(EpistemeDesktopWindowIconResource), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + Text( + text = appTitle, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "Opening your library", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp + ) + } } } } internal const val ComposeInteropBlendingProperty = "compose.interop.blending" internal const val ComposeInteropBlendingEnabled = "true" +private const val DesktopWindowStatePersistDebounceMillis = 450L +private val DesktopDefaultAppSeedColor = Color(0xFFFFB300) internal fun configureComposeSwingInterop() { // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed @@ -350,20 +629,320 @@ internal fun configureComposeSwingInterop() { } } -private data class DesktopWebViewRuntimeState( +@Composable +private fun DesktopWindowStatePersistenceEffect( + windowState: WindowState, + store: DesktopWindowStateStore, + enabled: Boolean +) { + val persistenceEnabled by rememberUpdatedState(enabled) + LaunchedEffect(windowState, store) { + snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } + .distinctUntilChanged() + .collectLatest { snapshot -> + if (!persistenceEnabled || snapshot == null) return@collectLatest + delay(DesktopWindowStatePersistDebounceMillis) + if (persistenceEnabled) { + withContext(Dispatchers.IO) { + store.save(snapshot) + } + } + } + } +} + +@Composable +private fun DesktopReaderFullscreenEffect( + window: Component?, + enabled: Boolean +) { + val awtWindow = window as? java.awt.Window ?: return + val fullscreenSnapshot = remember(awtWindow) { + AtomicReference() + } + val pendingExitSnapshot = remember(awtWindow) { + AtomicReference() + } + + LaunchedEffect(awtWindow, enabled) { + if (!enabled && fullscreenSnapshot.get() == null) { + return@LaunchedEffect + } + if (enabled) { + EventQueue.invokeLater { + awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot) + } + } + delay(if (enabled) 180L else 80L) + EventQueue.invokeLater { + if (enabled) { + pendingExitSnapshot.set(null) + awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot) + } else { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot) + } + } + delay(120L) + EventQueue.invokeLater { + if (!enabled) { + awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null)) + } + awtWindow.refreshDesktopReaderWindowFocus() + } + } + + DisposableEffect(awtWindow) { + onDispose { + EventQueue.invokeLater { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot) + } + } + } +} + +private data class DesktopReaderFullscreenSnapshot( + val device: GraphicsDevice?, + val frameState: Int?, + val frameBounds: Rectangle?, + val alwaysOnTop: Boolean +) + +private fun java.awt.Window.enterDesktopReaderFullscreen( + snapshotRef: AtomicReference +) { + if (!isDisplayable) return + focusableWindowState = true + captureDesktopReaderFullscreenSnapshot(snapshotRef) + applyDesktopReaderBorderlessFullscreen(snapshotRef.get()) + refreshDesktopReaderWindowFocus() +} + +private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot( + snapshotRef: AtomicReference +) { + snapshotRef.compareAndSet( + null, + DesktopReaderFullscreenSnapshot( + device = graphicsConfiguration?.device, + frameState = (this as? Frame)?.extendedState, + frameBounds = bounds.desktopReaderCopy(), + alwaysOnTop = isAlwaysOnTop + ) + ) +} + +private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) { + if (!isDisplayable) return + val device = snapshot?.device ?: graphicsConfiguration?.device + val frame = this as? Frame + focusableWindowState = true + if (frame != null) { + frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv() + frame.state = Frame.NORMAL + } + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds + } + runCatching { + isAlwaysOnTop = true + } +} + +private fun java.awt.Window.beginDesktopReaderFullscreenExit( + snapshotRef: AtomicReference, + pendingExitSnapshotRef: AtomicReference? = null +) { + val snapshot = snapshotRef.getAndSet(null) + if (snapshot == null) return + pendingExitSnapshotRef?.set(snapshot) + val device = snapshot.device ?: graphicsConfiguration?.device + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + (this as? Frame)?.let { frame -> + frame.state = Frame.NORMAL + frame.extendedState = Frame.NORMAL + } +} + +private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) { + if (snapshot == null) return + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + val frame = this as? Frame + if (frame == null) { + bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + return + } + val restoreMaximized = snapshot.frameState?.let { state -> + state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH + } == true + frame.extendedState = Frame.NORMAL + frame.state = Frame.NORMAL + frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + if (restoreMaximized) { + frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds() + EventQueue.invokeLater { + if (frame.isDisplayable && frame.isShowing) { + frame.extendedState = Frame.MAXIMIZED_BOTH + } + } + } + frame.toFront() + frame.requestFocus() + frame.validate() +} + +private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle { + return defaultConfiguration.bounds.desktopReaderCopy() +} + +private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? { + val configuration = defaultConfiguration ?: return null + return runCatching { + val bounds = configuration.bounds + val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration) + Rectangle( + bounds.x + insets.left, + bounds.y + insets.top, + (bounds.width - insets.left - insets.right).coerceAtLeast(1), + (bounds.height - insets.top - insets.bottom).coerceAtLeast(1) + ) + }.getOrNull() +} + +private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle { + val usableBounds = device?.desktopReaderUsableBounds() + ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820) + val source = this ?: usableBounds + val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640)) + val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480)) + val looksFullscreen = source.x <= usableBounds.x && + source.y <= usableBounds.y && + source.width >= usableBounds.width && + source.height >= usableBounds.height + if (looksFullscreen) { + return usableBounds.desktopReaderCopy() + } + val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x) + val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y) + return Rectangle( + source.x.coerceIn(usableBounds.x, maxX), + source.y.coerceIn(usableBounds.y, maxY), + width, + height + ) +} + +private fun Rectangle.desktopReaderCopy(): Rectangle { + return Rectangle(x, y, width, height) +} + +private fun java.awt.Window.refreshDesktopReaderWindowFocus() { + if (!isDisplayable) return + if (this is Frame && extendedState and Frame.ICONIFIED != 0) { + extendedState = extendedState and Frame.ICONIFIED.inv() + } + toFront() + requestFocus() + requestFocusInWindow() + focusOwner?.requestFocus() +} + +@Composable +private fun DesktopReaderFullscreenKeyEffect( + enabled: Boolean, + onKeyPressed: (AwtKeyEvent) -> Boolean +) { + val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) + DisposableEffect(enabled) { + if (!enabled) { + onDispose {} + } else { + val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val dispatcher = KeyEventDispatcher { event -> + val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true + !modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) + } + focusManager.addKeyEventDispatcher(dispatcher) + onDispose { + focusManager.removeKeyEventDispatcher(dispatcher) + } + } + } +} + +private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean { + val windowTitle = when (this) { + is java.awt.Dialog -> title + is Frame -> title + else -> "" + } + return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Popup") +} + +private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" + +internal data class DesktopWebViewRuntimeState( val initialized: Boolean = false, val restartRequired: Boolean = false, val downloadProgress: Float = -1f, val errorMessage: String? = null ) +internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean { + return readerSurface == ReaderFeatureSurface.TEXT_READER +} + +internal fun shouldStartDesktopWebViewRuntime( + requested: Boolean, + state: DesktopWebViewRuntimeState +): Boolean { + return requested && !state.initialized && !state.restartRequired && state.errorMessage == null +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EpistemeDesktopApp(window: Component? = null) { +private fun EpistemeDesktopApp( + window: Component? = null, + appWindowPlacement: WindowPlacement, + readerFullscreen: Boolean, + onReaderFullscreenChange: (Boolean) -> Unit +) { + val desktopBuildProfile = remember { currentDesktopBuildProfile() } + val featurePolicy = desktopBuildProfile.featurePolicy val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } val readerEngine = remember { ReaderEngine() } val libraryDatabase = remember { DesktopLibraryDatabase() } - val customFontStore = remember { DesktopCustomFontStore() } + val desktopBookImporter = remember { DesktopBookImporter() } + val customFontStore = remember { + DesktopCustomFontStore( + googleFontsDownloadAvailable = { featurePolicy.googleFontsDownload } + ) + } val opdsRepository = remember { DesktopOpdsRepository() } val opdsController = remember { SharedOpdsController( @@ -372,44 +951,36 @@ private fun EpistemeDesktopApp(window: Component? = null) { ) } val aiByokStore = remember { DesktopAiByokStore() } - var aiByokSettings by remember { mutableStateOf(aiByokStore.load()) } + var aiByokSettings by remember { + mutableStateOf(aiByokStore.load().withDesktopFeaturePolicy(featurePolicy)) + } val desktopAiAdapter = remember { - DesktopByokAiAdapter { aiByokSettings } + DesktopByokAiAdapter( + settingsProvider = { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) }, + networkAccess = { featurePolicy.networkAccess } + ) } val desktopTtsAdapter = remember { - DesktopGeminiCloudTtsAdapter(settingsProvider = { aiByokSettings }) + DesktopGeminiCloudTtsAdapter( + settingsProvider = { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) }, + networkAccess = { featurePolicy.networkAccess } + ) } - val initialLibrarySnapshot = remember { libraryDatabase.load() } + val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() } val scope = rememberCoroutineScope() var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) } + var webViewRuntimeRequested by remember { mutableStateOf(false) } var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } + val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - KCEF.init( - builder = { - installDir(File("kcef-bundle")) - progress { - onDownloading { - webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f)) - } - onInitialized { - webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null) - } - } - settings { - cachePath = File("cache").absolutePath - } - }, - onError = { error -> - webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString()) - }, - onRestartRequired = { - webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true) - } - ) - } - } + EpistemeDesktopWindowDecorationEffect( + window = window, + hideDecoration = readerFullscreen && !appWindowFullscreen + ) + DesktopReaderFullscreenEffect( + window = window, + enabled = readerFullscreen && !appWindowFullscreen + ) DisposableEffect(Unit) { onDispose { @@ -439,8 +1010,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { appTextDimFactorDark = initialLibrarySnapshot.appTextDimFactorDark, appSeedColor = initialLibrarySnapshot.appSeedColor, customAppThemes = initialLibrarySnapshot.customAppThemes, + readerDefaultSettings = initialLibrarySnapshot.readerDefaultSettings, + pdfReaderDefaultSettings = initialLibrarySnapshot.pdfReaderDefaultSettings, readerToolbarPreferences = initialLibrarySnapshot.readerToolbarPreferences, readerHighlightPalette = initialLibrarySnapshot.readerHighlightPalette, + pdfHighlighterPalette = initialLibrarySnapshot.pdfHighlighterPalette, readerTtsReplacementPreferences = initialLibrarySnapshot.readerTtsReplacementPreferences ) mutableStateOf( @@ -461,21 +1035,74 @@ private fun EpistemeDesktopApp(window: Component? = null) { mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) } var activeReaderBookId by remember { mutableStateOf(null) } - var readerSession by remember { mutableStateOf(readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())) } + val desktopEpubPaginationCache = remember { SharedEpubPaginationCache() } + var epubPaginationCacheGeneration by remember { mutableStateOf(0) } + LaunchedEffect(webViewRuntimeRequested) { + if (!shouldStartDesktopWebViewRuntime(webViewRuntimeRequested, webViewRuntimeState)) { + return@LaunchedEffect + } + + val webViewBundleDir = withContext(Dispatchers.IO) { bundledDesktopWebViewDir() } + val webViewBundlePresent = withContext(Dispatchers.IO) { + isBundledDesktopWebViewPresent(webViewBundleDir) + } + if (!webViewBundlePresent) { + webViewRuntimeState = webViewRuntimeState.copy( + errorMessage = "Bundled embedded webview is missing from ${webViewBundleDir.absolutePath}." + ) + return@LaunchedEffect + } + + runCatching { + withContext(Dispatchers.IO) { + KCEF.init( + builder = { + installDir(webViewBundleDir) + progress { + onDownloading { + webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f)) + } + onInitialized { + webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null) + } + } + settings { + cachePath = File(desktopUserCacheRoot(), "kcef").absolutePath + } + }, + onError = { error -> + webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString()) + }, + onRestartRequired = { + webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true) + } + ) + } + }.onFailure { error -> + webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error.message ?: error.toString()) + } + } + var readerSession by remember { mutableStateOf(readerEngine.createSession(desktopEmptyReaderBook())) } + LaunchedEffect(readerSession.reader.book.id, readerSession.reader.settings.readingMode) { + if ( + readerSession.reader.book.chapters.isNotEmpty() && + readerSession.reader.settings.readingMode == ReaderReadingMode.VERTICAL + ) { + webViewRuntimeRequested = true + } + } var readerExtrasState by remember { mutableStateOf( ReaderExtrasState( cloudTts = ReaderCloudTtsState( - isAvailable = aiByokSettings.isCloudTtsAvailable, - cacheSummary = desktopTtsAdapter.cacheSummary( - readerSession.reader.book.title, - aiByokSettings.sanitized().ttsSpeakerId - ) + isAvailable = aiByokSettings.isCloudTtsAvailable ) ) ) } var activePdfDocument by remember { mutableStateOf(null) } + var openingReader by remember { mutableStateOf(null) } + var nextReaderOpenRequestId by remember { mutableStateOf(0L) } var showCreateShelfDialog by remember { mutableStateOf(false) } var showCreateSmartShelfDialog by remember { mutableStateOf(false) } var shelfToRename by remember { mutableStateOf(null) } @@ -484,8 +1111,12 @@ private fun EpistemeDesktopApp(window: Component? = null) { var showAddToShelfDialog by remember { mutableStateOf(false) } var showTagSelectionDialog by remember { mutableStateOf(false) } var showAiByokSettingsDialog by remember { mutableStateOf(false) } + var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) } + var showClearBookCacheDialog by remember { mutableStateOf(false) } + var settingsQuery by remember { mutableStateOf("") } + var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } var bookInfoDialogFor by remember { mutableStateOf(null) } - var bookEditDialogFor by remember { mutableStateOf(null) } + var bookInfoInitiallyEditing by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } var opdsState by remember { mutableStateOf(opdsController.state) } @@ -536,8 +1167,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { appTextDimFactorDark = projected.appTextDimFactorDark, appSeedColor = projected.appSeedColor, customAppThemes = projected.customAppThemes, + readerDefaultSettings = projected.readerDefaultSettings, + pdfReaderDefaultSettings = projected.pdfReaderDefaultSettings, readerToolbarPreferences = projected.readerToolbarPreferences, readerHighlightPalette = projected.readerHighlightPalette, + pdfHighlighterPalette = projected.pdfHighlighterPalette, readerTtsReplacementPreferences = projected.readerTtsReplacementPreferences ) ) @@ -563,7 +1197,19 @@ private fun EpistemeDesktopApp(window: Component? = null) { persistSnapshot(projected) } + fun clearDesktopBookCache() { + scope.launch { + withContext(Dispatchers.IO) { + desktopEpubPaginationCache.clearAll() + SharedJvmBookLoader.clearCache() + } + epubPaginationCacheGeneration++ + updateState(state.withBanner("Book cache cleared. EPUB pagination will be recreated on demand.")) + } + } + fun updateAiByokSettings(next: ReaderAiByokSettings) { + if (!featurePolicy.aiAndCloud) return val sanitized = next.sanitized() logDesktopTts( "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + @@ -592,7 +1238,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun currentReaderTtsCacheSummary() = - desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) + if (activeReaderBookId == null) { + ReaderTtsCacheSummary() + } else { + desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) + } fun readerCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, @@ -602,6 +1252,7 @@ private fun EpistemeDesktopApp(window: Component? = null) { ) fun openReaderExternalLookup(action: ReaderExternalLookupAction, text: String) { + if (!featurePolicy.externalLookup) return val normalizedText = text.trim() if (normalizedText.isBlank()) return openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) @@ -638,26 +1289,45 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun syncBookSidecars(book: BookItem) { - if (book.sourceFolder.isNullOrBlank()) return + if (book.sourceFolder.isNullOrBlank()) { + logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}") + return + } + logDesktopFolderSync( + "bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) scope.launch(Dispatchers.IO) { DesktopLocalFolderSync.saveBookSidecars(book) } } - fun updateActiveBookReadingState(pageIndex: Int, progress: Float, session: ReaderSessionState? = null) { + fun updateActiveBookReadingState( + pageIndex: Int, + progress: Float, + session: ReaderSessionState? = null, + pdfViewport: SharedPdfReaderViewport? = null + ) { activeReaderBookId?.let { bookId -> var updatedBook: BookItem? = null + var shouldSyncSidecars = false val next = state.copy( rawLibraryBooks = state.rawLibraryBooks.map { book -> if (book.id == bookId) { + val readerPosition = session?.navigationLocator ?: book.readerPosition + shouldSyncSidecars = session != null || + book.lastPageIndex != pageIndex || + book.progressPercentage != progress || + book.readerPosition != readerPosition book.copy( progressPercentage = progress, timestamp = System.currentTimeMillis(), isRecent = true, lastPageIndex = pageIndex, + readerPosition = readerPosition, readerSettings = session?.reader?.settings ?: book.readerSettings, readerBookmarks = session?.bookmarks ?: book.readerBookmarks, - readerHighlights = session?.highlights ?: book.readerHighlights + readerHighlights = session?.highlights ?: book.readerHighlights, + pdfReaderViewport = pdfViewport ?: book.pdfReaderViewport ).also { updatedBook = it } } else { book @@ -665,7 +1335,9 @@ private fun EpistemeDesktopApp(window: Component? = null) { } ) updateState(next) - updatedBook?.let(::syncBookSidecars) + if (shouldSyncSidecars) { + updatedBook?.let(::syncBookSidecars) + } } } @@ -834,12 +1506,12 @@ private fun EpistemeDesktopApp(window: Component? = null) { }.onFailure { error -> logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() - if (error is kotlinx.coroutines.CancellationException) { - readerExtrasState = readerExtrasState.copy( + readerExtrasState = if (error is kotlinx.coroutines.CancellationException) { + readerExtrasState.copy( cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped") ) } else { - readerExtrasState = readerExtrasState.copy( + readerExtrasState.copy( cloudTts = readerCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.") ) } @@ -905,9 +1577,30 @@ private fun EpistemeDesktopApp(window: Component? = null) { startReaderCloudTts(ReaderTtsReadScope.PAGE, selectionChunks) } - fun importFiles(files: List) { - val importableFiles = files.filter { it.desktopFileType() in DesktopReadableFileTypes } - if (importableFiles.isEmpty() && files.isNotEmpty()) { + fun finishImportFiles( + files: List, + failedCount: Int, + onImported: (List) -> Unit = {} + ) { + val importStart = System.currentTimeMillis() + val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } + val importPlan = SharedImportPlanner.plan( + files = files, + existingBookIds = existingIds, + platform = ReaderPlatform.DESKTOP, + nowMillis = importStart + ) + val counts = SharedImportOutcomeCounts( + addedCount = importPlan.importedCount, + duplicateCount = importPlan.duplicateCount, + unsupportedCount = importPlan.unsupportedCount, + failedCount = failedCount + ) + if (files.isEmpty() && failedCount > 0) { + updateState(state.withBanner("Could not import ${failedCount} file(s).", isError = true)) + return + } + if (importPlan.supportedFiles.isEmpty() && files.isNotEmpty()) { updateState( state.withBanner( "No supported desktop reader files were selected. " + @@ -917,34 +1610,22 @@ private fun EpistemeDesktopApp(window: Component? = null) { ) return } - val skipped = files.size - importableFiles.size - val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } - val importablePaths = importableFiles - .mapNotNull { it.localPath ?: it.uriString } - .toSet() - val syncedFolders = mergeSyncedFolders( - existing = state.syncedFolders, - folderRoots = importableFiles.mapNotNull { it.sourceFolder }.distinct(), - nowMillis = System.currentTimeMillis() - ) - val next = state.withImportedFiles(importableFiles) - .copy(syncedFolders = syncedFolders) + val next = state.copy(rawLibraryBooks = importPlan.importedBooks + state.rawLibraryBooks) .let { when { - skipped > 0 -> it.withBanner("Imported supported files. Skipped $skipped unsupported file(s).") + counts.addedCount > 0 && (counts.unsupportedCount > 0 || counts.failedCount > 0) -> { + val skippedCount = counts.unsupportedCount + counts.failedCount + it.withBanner("Imported ${counts.addedCount} file(s). Skipped ${skippedCount} file(s).") + } + counts.addedCount > 0 -> it.withBanner("Imported ${counts.addedCount} file(s).") + counts.duplicateCount > 0 -> it.withBanner("Those files are already in the library.") + counts.failedCount > 0 -> it.withBanner("Could not import ${counts.failedCount} file(s).", isError = true) else -> it } } updateState(next) - val targetBookIds = next.rawLibraryBooks - .asSequence() - .filter { book -> - book.id !in existingIds || - book.path in importablePaths || - book.id in importablePaths - } - .map { it.id } - .toSet() + onImported(importPlan.importedBooks) + val targetBookIds = importPlan.importedBooks.mapTo(mutableSetOf()) { it.id } if (targetBookIds.isEmpty()) return val originalTargetBooksById = next.rawLibraryBooks .filter { it.id in targetBookIds } @@ -976,8 +1657,33 @@ private fun EpistemeDesktopApp(window: Component? = null) { } } - fun syncLocalFolders(targetFolder: File? = null, showBanner: Boolean = true) { + fun importFiles(files: List, onImported: (List) -> Unit = {}) { + if (files.isEmpty()) return + updateState(state.withBanner("Importing ${files.size} file(s)...")) + scope.launch { + val preparedImport = withContext(Dispatchers.IO) { + desktopBookImporter.prepareImports(files) + } + finishImportFiles( + files = preparedImport.files, + failedCount = preparedImport.failedCount, + onImported = onImported + ) + } + } + + fun syncLocalFolders( + targetFolder: File? = null, + showBanner: Boolean = true, + metadataOnly: Boolean = false + ) { + val mode = if (metadataOnly) "metadata" else "full" + logDesktopFolderSync( + "ui.sync.request mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + + "showBanner=$showBanner linkedFolders=${state.syncedFolders.size} books=${state.rawLibraryBooks.size}" + ) if (targetFolder == null && state.syncedFolders.isEmpty()) { + logDesktopFolderSync("ui.sync.skipNoFolders mode=$mode") updateState(state.withBanner("No local folders are linked yet.", isError = true)) return } @@ -985,7 +1691,12 @@ private fun EpistemeDesktopApp(window: Component? = null) { val snapshotState = state val snapshotShelfRefs = shelfRefs if (showBanner) { - updateState(state.withBanner("Folder sync: scanning local folders...")) + val message = if (metadataOnly) { + "Folder sync: updating metadata..." + } else { + "Folder sync: scanning local folders..." + } + updateState(state.withBanner(message)) } scope.launch { @@ -993,7 +1704,8 @@ private fun EpistemeDesktopApp(window: Component? = null) { DesktopLocalFolderSync.sync( state = snapshotState, shelfRefs = snapshotShelfRefs, - targetFolder = targetFolder + targetFolder = targetFolder, + metadataOnly = metadataOnly ) } val failedCount = result.failedFolders.size @@ -1004,9 +1716,16 @@ private fun EpistemeDesktopApp(window: Component? = null) { "Folder sync failed for $failedCount folder(s)." failedCount > 0 -> "Folder sync finished with $failedCount folder(s) skipped." + metadataOnly -> + "Folder metadata sync complete." else -> "Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed." } + logDesktopFolderSync( + "ui.sync.result mode=$mode failed=$failedCount message=\"${message.folderSyncPreview()}\" " + + "new=${stats.newBooks} updated=${stats.updatedBooks} remoteUpdates=${stats.remoteMetadataUpdates} " + + "removed=${stats.removedBooks} metadataExtracted=${metadataStats.updatedBooks}" + ) val completedState = if (showBanner || failedCount > 0) { result.state.withBanner(message, isError = failedCount > 0) } else { @@ -1018,17 +1737,28 @@ private fun EpistemeDesktopApp(window: Component? = null) { refs = result.shelfRefs ) if (activeReaderBookId != null && completedState.rawLibraryBooks.none { it.id == activeReaderBookId }) { + openingReader = null activePdfDocument?.close() activePdfDocument = null activeReaderBookId = null - readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + readerSession = readerEngine.createSession(desktopEmptyReaderBook()) selectedTab = SharedAppTab.HOME } } } + fun syncFolderMetadata(showBanner: Boolean = true) { + syncLocalFolders(showBanner = showBanner, metadataOnly = true) + } + + fun scanSyncedFolders(showBanner: Boolean = true) { + syncLocalFolders(showBanner = showBanner, metadataOnly = false) + } + fun importFolder(folder: File) { + logDesktopFolderSync("ui.importFolder.request folder=\"${folder.absolutePath.folderSyncPreview()}\"") if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) { + logDesktopFolderSync("ui.importFolder.skipNoSupportedFiles folder=\"${folder.absolutePath.folderSyncPreview()}\"") updateState(state.withBanner("That folder does not contain any supported desktop reader files.", isError = true)) return } @@ -1051,6 +1781,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { + if (!featurePolicy.googleFontsDownload) { + updateState(state.withBanner("Google Fonts download is unavailable in this desktop build.", isError = true)) + onComplete() + return + } scope.launch { val result = withContext(Dispatchers.IO) { customFontStore.downloadGoogleFont(fontName) @@ -1130,12 +1865,67 @@ private fun EpistemeDesktopApp(window: Component? = null) { } } - fun updateBookMetadata(updated: BookItem) { + fun applyBookMetadataUpdate(updated: BookItem) { val result = SharedLibraryEditor.updateBookMetadata(state, shelfRecords, shelfRefs, updated, System.currentTimeMillis()) replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) result.state.rawLibraryBooks.firstOrNull { it.id == updated.id }?.let(::syncBookSidecars) } + fun writeDesktopEpubMetadata(original: BookItem, updated: BookItem): BookItem { + val file = File(original.path ?: error("Book path is missing.")) + require(file.isFile && file.canWrite()) { "EPUB file is not writable." } + val backup = File( + File(desktopUserDataRoot(), "metadata_backups").apply { mkdirs() }, + "${original.id.toDesktopSafeFileName()}.epub" + ) + val snapshot = SharedEpubMetadataEditor.rewriteInPlace( + source = file, + backup = backup, + update = SharedEpubMetadataUpdate( + title = updated.title, + author = updated.author, + description = updated.description, + seriesName = updated.seriesName, + seriesIndex = updated.seriesIndex + ) + ) + return updated.copy( + title = snapshot.title ?: updated.title, + author = snapshot.author, + description = snapshot.description, + seriesName = snapshot.seriesName, + seriesIndex = snapshot.seriesIndex, + originalTitle = original.originalTitle ?: original.title, + originalAuthor = original.originalAuthor ?: original.author, + originalSeriesName = original.originalSeriesName ?: original.seriesName, + originalSeriesIndex = original.originalSeriesIndex ?: original.seriesIndex, + originalDescription = original.originalDescription ?: original.description, + fileSize = file.length(), + fileContentModifiedTimestamp = file.lastModified() + ) + } + + fun updateBookMetadata(updated: BookItem) { + val original = state.rawLibraryBooks.firstOrNull { it.id == updated.id } + if (original != null && original.type == FileType.EPUB && original.hasEmbeddedMetadataChange(updated)) { + scope.launch { + val rewritten = runCatching { + withContext(Dispatchers.IO) { + writeDesktopEpubMetadata(original, updated) + } + } + rewritten.onSuccess(::applyBookMetadataUpdate) + .onFailure { error -> + println("Failed to update EPUB metadata for ${updated.displayName}: ${error.message}") + updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata."))) + } + } + return + } + + applyBookMetadataUpdate(updated) + } + fun recordBookOpened(bookId: String) { val now = System.currentTimeMillis() val next = SharedLibraryEditor.markBookOpened(state, bookId, now) @@ -1144,8 +1934,83 @@ private fun EpistemeDesktopApp(window: Component? = null) { openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let(::syncBookSidecars) } + fun scheduleOpenedBookMetadataExtraction(book: BookItem) { + scope.launch { + val enriched = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichOpenedBook(book) + } + if (enriched == book) return@launch + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { current -> + if (current.id == book.id) { + current.withDesktopImportMetadata(enriched = enriched, original = book) + } else { + current + } + } + ) + ) + } + } + + fun schedulePdfEmbeddedAnnotationsLoad(document: DesktopPdfDocument) { + scope.launch { + delay(650L) + if (activePdfDocument?.path != document.path) return@launch + val annotations = withContext(Dispatchers.IO) { + DesktopPdfium.loadEmbeddedAnnotations(document) + } + if (activePdfDocument?.path == document.path) { + document.replaceEmbeddedAnnotations(annotations) + } + } + } + + fun applyReaderOpenResult(result: DesktopReaderOpenResult) { + if (openingReader?.requestId != result.opening.requestId) { + if (result is DesktopReaderOpenResult.Pdf && activePdfDocument?.path != result.document.path) { + result.document.close() + } + return + } + + openingReader = null + when (result) { + is DesktopReaderOpenResult.Failure -> { + selectedTab = result.opening.returnTab + updateState(state.withBanner(result.message, isError = true)) + } + + is DesktopReaderOpenResult.Pdf -> { + activePdfDocument?.takeIf { it.path != result.document.path }?.close() + activePdfDocument = result.document + activeReaderBookId = result.book.id + recordBookOpened(result.book.id) + selectedTab = SharedAppTab.READER + if (result.book.type == FileType.PDF) { + schedulePdfEmbeddedAnnotationsLoad(result.document) + } + } + + is DesktopReaderOpenResult.Text -> { + activePdfDocument?.close() + activePdfDocument = null + readerSession = result.session + activeReaderBookId = result.book.id + recordBookOpened(result.book.id) + selectedTab = SharedAppTab.READER + } + } + } + fun openReader(book: BookItem) { val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) + if (openingReader?.bookId == book.id) return + if (shouldRequestDesktopWebViewRuntime(desktopReaderSurface)) { + webViewRuntimeRequested = true + } + if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { val path = book.path if (path.isNullOrBlank()) { @@ -1158,72 +2023,29 @@ private fun EpistemeDesktopApp(window: Component? = null) { return } val streamReference = SharedOpdsStreamUri.parse(path) - if (streamReference != null) { - if (activePdfDocument?.path == path) { - activeReaderBookId = book.id - recordBookOpened(book.id) - selectedTab = SharedAppTab.READER - return - } - activePdfDocument?.close() - activePdfDocument = null - val document = runCatching { - DesktopPdfium.loadOpdsStream( - path = path, - title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName, - reference = streamReference, - catalog = opdsRepository.catalogById(streamReference.catalogId) - ) - }.getOrElse { error -> - updateState( - state.withBanner( - "Could not open OPDS stream: ${error.message ?: "unknown error"}", - isError = true - ) - ) - return - } - activePdfDocument = document - activeReaderBookId = book.id - recordBookOpened(book.id) - selectedTab = SharedAppTab.READER + if (streamReference != null && !featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) return } - val readerFile = File(path) - val readerPath = readerFile.absolutePath + val readerPath = streamReference?.let { path } ?: File(path).absolutePath if (activePdfDocument?.path == readerPath) { + openingReader = null activeReaderBookId = book.id recordBookOpened(book.id) selectedTab = SharedAppTab.READER return } - activePdfDocument?.close() - activePdfDocument = null - val document = runCatching { - if (book.type == FileType.PDF) { - DesktopPdfium.load(readerFile) - } else { - DesktopPdfium.loadComic(readerFile, book.type) - } - }.getOrElse { error -> - updateState( - state.withBanner( - "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " + - (error.message ?: "unknown error"), - isError = true - ) - ) + } else if ( + desktopReaderSurface == ReaderFeatureSurface.EPUB_READER || + desktopReaderSurface == ReaderFeatureSurface.TEXT_READER + ) { + if (activePdfDocument == null && activeReaderBookId == book.id) { + openingReader = null + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER return } - - activePdfDocument = document - activeReaderBookId = book.id - recordBookOpened(book.id) - selectedTab = SharedAppTab.READER - return - } - - if (desktopReaderSurface != ReaderFeatureSurface.EPUB_READER && desktopReaderSurface != ReaderFeatureSurface.TEXT_READER) { + } else { updateState( state.withBanner( "${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " + @@ -1233,42 +2055,84 @@ private fun EpistemeDesktopApp(window: Component? = null) { return } - val loadedBook = runCatching { - val path = book.path - if (path.isNullOrBlank()) { - SampleReaderBooks.desktopWelcomeBook() - } else { - SharedJvmBookLoader.load( - file = File(path), - type = book.type, - titleOverride = book.title?.takeIf { it.isNotBlank() }, - authorOverride = book.author?.takeIf { it.isNotBlank() } - ) - } - }.getOrElse { error -> - updateState(state.withBanner("Could not open ${book.type.name}: ${error.message ?: "unknown error"}", isError = true)) - return - } + scheduleOpenedBookMetadataExtraction(book) - activePdfDocument?.close() - activePdfDocument = null - val restoredSettings = book.readerSettings ?: readerSession.reader.settings - val restoredSession = readerEngine.createSession( - book = loadedBook, - settings = restoredSettings, - initialPageIndex = book.lastPageIndex ?: 0, - bookmarks = book.readerBookmarks, - highlights = book.readerHighlights + val opening = DesktopReaderOpening( + requestId = ++nextReaderOpenRequestId, + bookId = book.id, + title = book.cardTitleForMessage(), + formatLabel = SharedFileCapabilities.displayNameFor(book.type), + returnTab = selectedTab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY ) - val restoredProgress = book.progressPercentage - readerSession = if (book.lastPageIndex == null && restoredProgress != null) { - readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) - } else { - restoredSession - } - activeReaderBookId = book.id - recordBookOpened(book.id) + val readerDefaultSettings = state.readerDefaultSettings + openingReader = opening selectedTab = SharedAppTab.READER + + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + when (desktopReaderSurface) { + ReaderFeatureSurface.PDF_VIEWER -> { + val path = book.path.orEmpty() + val streamReference = SharedOpdsStreamUri.parse(path) + val document = if (streamReference != null) { + DesktopPdfium.loadOpdsStream( + path = path, + title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName, + reference = streamReference, + catalog = opdsRepository.catalogById(streamReference.catalogId) + ) + } else { + val readerFile = File(path) + if (book.type == FileType.PDF) { + DesktopPdfium.load(readerFile, loadEmbeddedAnnotations = false) + } else { + DesktopPdfium.loadComic(readerFile, book.type) + } + } + DesktopReaderOpenResult.Pdf(opening, book, document) + } + + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> { + val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.") + val loadedBook = SharedJvmBookLoader.load( + file = File(path), + type = book.type, + titleOverride = book.title?.takeIf { it.isNotBlank() }, + authorOverride = book.author?.takeIf { it.isNotBlank() } + ) + val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + val restoredSession = readerEngine.createSession( + book = loadedBook, + settings = restoredSettings, + initialPageIndex = book.lastPageIndex ?: 0, + initialLocator = book.readerPosition, + bookmarks = book.readerBookmarks, + highlights = book.readerHighlights + ) + val restoredProgress = book.progressPercentage + val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) { + readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) + } else { + restoredSession + } + DesktopReaderOpenResult.Text(opening, book, session) + } + + else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.") + } + }.getOrElse { error -> + DesktopReaderOpenResult.Failure( + opening = opening, + book = book, + message = "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " + + (error.message ?: "unknown error") + ) + } + } + applyReaderOpenResult(result) + } } fun removeFolder(shelf: Shelf) { @@ -1281,13 +2145,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { SharedLibraryEditor.removeFolder(state, shelfRecords, shelfRefs, shelf)?.let { replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) if (wasReadingRemovedBook) { + openingReader = null activePdfDocument?.close() activePdfDocument = null activeReaderBookId = null if (nextTabBook != null) { openReader(nextTabBook) } else { - readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + readerSession = readerEngine.createSession(desktopEmptyReaderBook()) selectedTab = SharedAppTab.HOME } } @@ -1296,10 +2161,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { fun closeReaderTab(book: BookItem) { val wasActive = activeReaderBookId == book.id + if (openingReader?.bookId == book.id) { + openingReader = null + } val remainingIds = state.openTabIds.filterNot { it == book.id } updateState(state.reduce(AppAction.BookTabClosed(book.id))) if (!wasActive) return + openingReader = null activePdfDocument?.close() activePdfDocument = null activeReaderBookId = null @@ -1309,16 +2178,17 @@ private fun EpistemeDesktopApp(window: Component? = null) { if (nextBook != null) { openReader(nextBook) } else { - readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + readerSession = readerEngine.createSession(desktopEmptyReaderBook()) selectedTab = SharedAppTab.HOME } } fun closeAllReaderTabs() { + openingReader = null activePdfDocument?.close() activePdfDocument = null activeReaderBookId = null - readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + readerSession = readerEngine.createSession(desktopEmptyReaderBook()) selectedTab = SharedAppTab.HOME updateState(state.reduce(AppAction.AllTabsClosed)) } @@ -1337,34 +2207,16 @@ private fun EpistemeDesktopApp(window: Component? = null) { ) return } - importFiles(listOf(importedFile)) - openReader( - BookItem( - id = file.absolutePath, - path = file.absolutePath, - type = type, - displayName = file.name, - timestamp = System.currentTimeMillis(), - title = file.nameWithoutExtension, - fileSize = file.length() - ) - ) + importFiles(listOf(importedFile)) { importedBooks -> + importedBooks.firstOrNull()?.let(::openReader) + } } fun importAndOpenPdf() { val file = choosePdfFile() ?: return - importFiles(listOf(file.toImportedBookFile())) - openReader( - BookItem( - id = file.absolutePath, - path = file.absolutePath, - type = FileType.PDF, - displayName = file.name, - timestamp = System.currentTimeMillis(), - title = file.nameWithoutExtension, - fileSize = file.length() - ) - ) + importFiles(listOf(file.toImportedBookFile())) { importedBooks -> + importedBooks.firstOrNull()?.let(::openReader) + } } fun emitOpds(next: com.aryan.reader.shared.opds.SharedOpdsScreenState) { @@ -1372,12 +2224,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun openOpdsCatalog(catalog: OpdsCatalog) { + if (!featurePolicy.opdsCatalogs) return scope.launch { opdsController.openCatalog(catalog, ::emitOpds) } } fun openOpdsFeedUrl(url: String) { + if (!featurePolicy.opdsCatalogs) return scope.launch { opdsController.openFeedUrl(url, ::emitOpds) } @@ -1390,12 +2244,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun searchOpds(query: String) { + if (!featurePolicy.opdsCatalogs) return scope.launch { opdsController.search(query, ::emitOpds) } } fun loadNextOpdsPage() { + if (!featurePolicy.opdsCatalogs) return scope.launch { opdsController.loadNextPage(::emitOpds) } @@ -1411,7 +2267,7 @@ private fun EpistemeDesktopApp(window: Component? = null) { activePdfDocument?.close() activePdfDocument = null activeReaderBookId = null - readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + readerSession = readerEngine.createSession(desktopEmptyReaderBook()) selectedTab = SharedAppTab.HOME } updateState( @@ -1425,6 +2281,10 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun downloadOpdsBook(entry: OpdsEntry, acquisition: OpdsAcquisition) { + if (!featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS downloads are unavailable in this desktop build.", isError = true)) + return + } val catalog = opdsState.currentCatalog scope.launch { emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, 0f))) @@ -1453,6 +2313,10 @@ private fun EpistemeDesktopApp(window: Component? = null) { } fun streamOpdsBook(entry: OpdsEntry, catalog: OpdsCatalog?) { + if (!featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) + return + } val pageCount = entry.pseCount val urlTemplate = entry.pseUrlTemplate if (pageCount == null || pageCount <= 0 || urlTemplate.isNullOrBlank()) { @@ -1497,7 +2361,7 @@ private fun EpistemeDesktopApp(window: Component? = null) { LaunchedEffect(Unit) { if (state.syncedFolders.isNotEmpty()) { - syncLocalFolders(showBanner = false) + scanSyncedFolders(showBanner = false) } } @@ -1525,6 +2389,12 @@ private fun EpistemeDesktopApp(window: Component? = null) { appTextDimFactorDark = state.appTextDimFactorDark, appSeedColor = state.appSeedColor ) { + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) Box( Modifier .fillMaxSize() @@ -1540,12 +2410,25 @@ private fun EpistemeDesktopApp(window: Component? = null) { appSeedColor = state.appSeedColor, customAppThemes = state.customAppThemes, isTabsEnabled = state.isTabsEnabled, - onTabSelected = { selectedTab = it }, + featurePolicy = featurePolicy, + onTabSelected = { tab -> + val nextTab = if (tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs) { + SharedAppTab.HOME + } else { + tab + } + if (nextTab == SharedAppTab.SETTINGS) { + settingsQuery = "" + settingsDestination = SharedSettingsDestination.ROOT + } + selectedTab = nextTab + }, onImportFiles = { importFiles(chooseFiles()) }, onImportFolder = { chooseFolder()?.let(::importFolder) }, onSyncRequested = { - syncLocalFolders() + scanSyncedFolders() }, + onFolderMetadataSyncRequested = { syncFolderMetadata() }, onAppThemeModeChange = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, onAppContrastOptionChange = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, onAppTextDimFactorLightChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, @@ -1554,7 +2437,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { onCustomAppThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, onCustomAppThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, onTabsEnabledChange = { enabled -> updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) }, - onAiSettingsRequested = { showAiByokSettingsDialog = true } + onAiSettingsRequested = if (featurePolicy.aiAndCloud) { + { showAiByokSettingsDialog = true } + } else { + null + } ) { tab -> when (tab) { SharedAppTab.HOME -> HomeScreen( @@ -1567,15 +2454,108 @@ private fun EpistemeDesktopApp(window: Component? = null) { onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { bookInfoDialogFor = it }, - onEditBook = { bookEditDialogFor = it }, + onShowBookInfo = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = it + }, + onEditBook = { + bookInfoInitiallyEditing = true + bookInfoDialogFor = it + }, onTagSelectedBooks = { showTagSelectionDialog = true }, onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, onOpenTab = ::openReader, onCloseTab = ::closeReaderTab, onCloseAllTabs = ::closeAllReaderTabs, onRecentLimitChange = { limit -> updateState(state.reduce(LibraryAction.RecentLimitChanged(limit))) }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.HomePinToggled(book.id))) } + onTogglePinned = { book -> updateState(state.reduce(AppAction.HomePinToggled(book.id))) }, + onOpenSettings = { + settingsQuery = "" + settingsDestination = SharedSettingsDestination.ROOT + selectedTab = SharedAppTab.SETTINGS + } + ) + + SharedAppTab.SETTINGS -> SharedSettingsHub( + model = sharedSettingsHubModel( + SharedSettingsHubInput( + platform = SharedSettingsPlatform.DESKTOP, + featurePolicy = featurePolicy, + isDebugBuild = false, + isSignedIn = false, + isProUser = true, + syncAvailable = false, + folderSyncAvailable = true, + aiSettingsAvailable = featurePolicy.aiAndCloud, + includeLanguage = false, + includeScreenCaptureProtection = false, + includeExternalFileBehavior = false, + includeStrictFileFilter = false, + includeReaderTabs = false, + includeHideReaderAi = false, + isTabsEnabled = state.isTabsEnabled, + isFolderSyncEnabled = state.isFolderSyncEnabled + ) + ), + query = settingsQuery, + onQueryChange = { settingsQuery = it }, + destination = settingsDestination, + onDestinationChange = { settingsDestination = it }, + readerDefaultSettings = state.readerDefaultSettings, + onReaderDefaultSettingsChange = { settings -> + updateState(state.reduce(AppAction.ReaderDefaultSettingsChanged(settings))) + }, + pdfReaderDefaultSettings = state.pdfReaderDefaultSettings, + onPdfReaderDefaultSettingsChange = { settings -> + updateState(state.reduce(AppAction.PdfReaderDefaultSettingsChanged(settings))) + }, + readerToolbarPreferences = state.readerToolbarPreferences, + onReaderToolbarPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) + }, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + customFonts = customFonts, + onPickCustomFont = { importCustomFont(chooseFontFile())?.path }, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = ::importDesktopReaderTexture, + onAction = { action -> + when (action) { + SharedSettingsAction.APP_THEME -> showDesktopAppThemeSettingsDialog = true + SharedSettingsAction.TABS_TOGGLE -> updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) + SharedSettingsAction.FOLDER_SYNC -> updateState(state.reduce(AppAction.FolderSyncEnabledChanged(!state.isFolderSyncEnabled))) + SharedSettingsAction.AI_SETTINGS -> showAiByokSettingsDialog = true + SharedSettingsAction.CUSTOM_FONTS -> selectedTab = SharedAppTab.CUSTOM_FONTS + SharedSettingsAction.HELP_FEEDBACK -> selectedTab = SharedAppTab.FEEDBACK + SharedSettingsAction.SUPPORT -> selectedTab = SharedAppTab.SUPPORT + SharedSettingsAction.ABOUT -> selectedTab = SharedAppTab.ABOUT + SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true + SharedSettingsAction.CLEAR_REFLOW_CACHE, + SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, + SharedSettingsAction.TEST_PANEL_DETECTION, + SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, + SharedSettingsAction.EXPORT_LOGS, + SharedSettingsAction.DEBUG_ACTIONS, + SharedSettingsAction.DEVICE_MANAGEMENT, + SharedSettingsAction.SIGN_IN, + SharedSettingsAction.SIGN_OUT, + SharedSettingsAction.CLOUD_SYNC, + SharedSettingsAction.LANGUAGE, + SharedSettingsAction.RECENT_LIMIT, + SharedSettingsAction.STRICT_FILE_FILTER, + SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, + SharedSettingsAction.SCREEN_CAPTURE_PROTECTION, + SharedSettingsAction.HIDE_READER_AI, + SharedSettingsAction.TTS_SETTINGS, + SharedSettingsAction.PDF_READER_DEFAULTS, + SharedSettingsAction.TEXT_READER_DEFAULTS, + SharedSettingsAction.READER_TOOLBAR, + SharedSettingsAction.TTS_REPLACEMENTS, + SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit + } + } ) SharedAppTab.LIBRARY -> LibraryScreen( @@ -1591,8 +2571,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { bookInfoDialogFor = it }, - onEditBook = { bookEditDialogFor = it }, + onShowBookInfo = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = it + }, + onEditBook = { + bookInfoInitiallyEditing = true + bookInfoDialogFor = it + }, onCreateShelf = { showCreateShelfDialog = true }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, @@ -1600,6 +2586,8 @@ private fun EpistemeDesktopApp(window: Component? = null) { onRemoveFolder = { folderToRemove = it }, onTagSelectedBooks = { showTagSelectionDialog = true }, onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, + onSyncFolderMetadata = { syncFolderMetadata() }, + onScanFolders = { scanSyncedFolders() }, onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } ) @@ -1609,8 +2597,14 @@ private fun EpistemeDesktopApp(window: Component? = null) { onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, selectedBookIds = state.selectedBookIds, pinnedBookIds = state.pinnedLibraryBookIds, - onShowBookInfo = { bookInfoDialogFor = it }, - onEditBook = { bookEditDialogFor = it }, + onShowBookInfo = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = it + }, + onEditBook = { + bookInfoInitiallyEditing = true + bookInfoDialogFor = it + }, onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, onCreateShelf = { showCreateShelfDialog = true }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, @@ -1619,32 +2613,45 @@ private fun EpistemeDesktopApp(window: Component? = null) { onRemoveFolder = { folderToRemove = it } ) - SharedAppTab.CATALOGS -> SharedOpdsScreen( - state = opdsState, - localLibraryBooks = state.rawLibraryBooks, - onOpenCatalog = ::openOpdsCatalog, - onOpenFeedUrl = ::openOpdsFeedUrl, - onNavigateBack = ::navigateOpdsBack, - onSearch = ::searchOpds, - onLoadNextPage = ::loadNextOpdsPage, - onAddCatalog = { title, url, username, password -> - emitOpds(opdsController.addCatalog(title, url, username, password)) - }, - onUpdateCatalog = { id, title, url, username, password -> - emitOpds(opdsController.updateCatalog(id, title, url, username, password)) - }, - onRemoveCatalog = ::removeOpdsCatalog, - onDownloadBook = ::downloadOpdsBook, - onReadBook = ::openReader, - onStreamBook = ::streamOpdsBook, - onClearError = { emitOpds(opdsController.clearError()) } - ) + SharedAppTab.CATALOGS -> { + if (featurePolicy.opdsCatalogs) { + SharedOpdsScreen( + state = opdsState, + localLibraryBooks = state.rawLibraryBooks, + onOpenCatalog = ::openOpdsCatalog, + onOpenFeedUrl = ::openOpdsFeedUrl, + onNavigateBack = ::navigateOpdsBack, + onSearch = ::searchOpds, + onLoadNextPage = ::loadNextOpdsPage, + onAddCatalog = { title, url, username, password -> + emitOpds(opdsController.addCatalog(title, url, username, password)) + }, + onUpdateCatalog = { id, title, url, username, password -> + emitOpds(opdsController.updateCatalog(id, title, url, username, password)) + }, + onRemoveCatalog = ::removeOpdsCatalog, + onDownloadBook = ::downloadOpdsBook, + onReadBook = ::openReader, + onStreamBook = ::streamOpdsBook, + onClearError = { emitOpds(opdsController.clearError()) }, + coverContent = { entry, modifier -> + DesktopOpdsCoverImage( + entry = entry, + catalog = opdsState.currentCatalog, + modifier = modifier + ) + } + ) + } else { + Box(Modifier.fillMaxSize()) + } + } SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen( fonts = customFonts, onImportFont = { importCustomFont(chooseFontFile()) }, onDeleteFont = ::deleteCustomFont, - googleFontsAvailable = true, + googleFontsAvailable = featurePolicy.googleFontsDownload, getGoogleFonts = { customFontStore.loadGoogleFontsList() }, onDownloadGoogleFont = ::downloadGoogleFont, fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() } @@ -1653,7 +2660,8 @@ private fun EpistemeDesktopApp(window: Component? = null) { SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen( onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) }, onEmailSupport = { - openExternalUrl("mailto:$EpistemeSupportEmail?subject=${EpistemeFeedbackSubject.urlEncode()}") + val subject = desktopFeedbackSubject(desktopBuildProfile).urlEncode() + openExternalUrl("mailto:$EpistemeSupportEmail?subject=$subject") } ) @@ -1664,27 +2672,55 @@ private fun EpistemeDesktopApp(window: Component? = null) { SharedAppTab.ABOUT -> SharedAboutScreen( versionName = desktopAppVersionName(), - buildLabel = "Desktop build", - onOpenSource = { openExternalUrl(EpistemeSourceUrl) }, - onOpenIssues = { openExternalUrl(EpistemeIssuesUrl) } + buildLabel = desktopBuildProfile.buildLabel, + onOpenSource = if (featurePolicy.projectLinks) { + { openExternalUrl(EpistemeSourceUrl) } + } else { + null + }, + onOpenIssues = if (featurePolicy.projectLinks) { + { openExternalUrl(EpistemeIssuesUrl) } + } else { + null + } ) SharedAppTab.READER -> { + val opening = openingReader val pdfDocument = activePdfDocument - if (pdfDocument != null) { + if (opening != null) { + DesktopReaderOpeningScreen( + opening = opening, + onReturnToLibrary = { + openingReader = null + selectedTab = opening.returnTab + } + ) + } else if (pdfDocument != null) { PdfReaderScreen( document = pdfDocument, initialPageIndex = activeReaderBookId ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.lastPageIndex } ?: 0, + initialViewport = activeReaderBookId + ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.pdfReaderViewport }, initialReaderSettings = activeReaderBookId - ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.readerSettings }, - onOpenPdf = ::importAndOpenPdf, - onOpenBook = ::importAndOpenBook, - onPageStateChange = { page, progress -> - updateActiveBookReadingState(page, progress) + ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId } } + ?.let { book -> resolvedDesktopReaderSettings(book, state.pdfReaderDefaultSettings) } + ?: state.pdfReaderDefaultSettings, + onReturnToLibrary = { + onReaderFullscreenChange(false) + selectedTab = SharedAppTab.LIBRARY + }, + onFullscreenChange = onReaderFullscreenChange, + onPageStateChange = { page, progress, viewport -> + updateActiveBookReadingState(page, progress, pdfViewport = viewport) }, onReaderSettingsChange = ::updateActiveBookReaderSettings, + pdfHighlighterPalette = state.pdfHighlighterPalette, + onPdfHighlighterPaletteChange = { palette -> + updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette))) + }, customTextureIds = readerCustomTextureIds, onImportTexture = ::importDesktopReaderTexture, onLocalSidecarsChanged = { @@ -1694,7 +2730,12 @@ private fun EpistemeDesktopApp(window: Component? = null) { }, aiByokSettings = aiByokSettings, aiAdapter = desktopAiAdapter, - ttsAdapter = desktopTtsAdapter + ttsAdapter = desktopTtsAdapter, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + featurePolicy = featurePolicy ) } else { ReaderScreen( @@ -1708,8 +2749,11 @@ private fun EpistemeDesktopApp(window: Component? = null) { session = updated ) }, - onOpenBook = ::importAndOpenBook, - onOpenPdf = ::importAndOpenPdf, + onReturnToLibrary = { + onReaderFullscreenChange(false) + selectedTab = SharedAppTab.LIBRARY + }, + onFullscreenChange = onReaderFullscreenChange, toolbarPreferences = state.readerToolbarPreferences, onToolbarPreferencesChange = { preferences -> updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) @@ -1729,8 +2773,13 @@ private fun EpistemeDesktopApp(window: Component? = null) { customFonts = customFonts, readerExtrasState = readerExtrasState, aiByokSettings = aiByokSettings, + externalLookupAvailable = featurePolicy.externalLookup, + cloudTtsControlsAvailable = featurePolicy.aiAndCloud, onExternalLookup = ::openReaderExternalLookup, onAiAction = ::runReaderAiAction, + onAiResultDismiss = { + readerExtrasState = readerExtrasState.copy(aiResult = ReaderAiResultState()) + }, onCloudTtsToggle = ::toggleReaderCloudTts, onCloudTtsStart = ::startReaderCloudTts, onCloudTtsPauseResume = ::pauseResumeReaderCloudTts, @@ -1740,7 +2789,10 @@ private fun EpistemeDesktopApp(window: Component? = null) { readerTextureDataUri = DesktopReaderTextures::dataUriFor, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = ::importDesktopReaderTexture, - webViewRuntimeState = webViewRuntimeState + webViewRuntimeState = webViewRuntimeState, + webViewNetworkAccessEnabled = featurePolicy.networkAccess, + epubPaginationCache = desktopEpubPaginationCache, + epubPaginationCacheGeneration = epubPaginationCacheGeneration ) } } @@ -1758,6 +2810,38 @@ private fun EpistemeDesktopApp(window: Component? = null) { ) } + if (showDesktopAppThemeSettingsDialog) { + SharedAppThemeSettingsDialog( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, + onDismiss = { showDesktopAppThemeSettingsDialog = false } + ) + } + + if (showClearBookCacheDialog) { + SharedConfirmDialog( + title = "Clear book cache", + body = "Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened.", + confirmLabel = "Clear", + onDismiss = { showClearBookCacheDialog = false }, + onConfirm = { + clearDesktopBookCache() + showClearBookCacheDialog = false + } + ) + } + if (showCreateShelfDialog) { SharedTextInputDialog( title = "Create shelf", @@ -1852,24 +2936,29 @@ private fun EpistemeDesktopApp(window: Component? = null) { } bookInfoDialogFor?.let { book -> + val canEditEmbeddedMetadata = book.type == FileType.EPUB && + book.path?.let { File(it).isFile && File(it).canWrite() } == true + val canRenameDisplayName = book.type != FileType.EPUB SharedBookInfoDialog( - book = book, - onDismiss = { bookInfoDialogFor = null }, - onEdit = { - bookEditDialogFor = book - bookInfoDialogFor = null - } - ) - } - - bookEditDialogFor?.let { book -> - SharedBookEditDialog( book = book, knownTags = state.allTags, - onDismiss = { bookEditDialogFor = null }, + initiallyEditing = bookInfoInitiallyEditing && (canEditEmbeddedMetadata || canRenameDisplayName), + canEditEmbeddedMetadata = canEditEmbeddedMetadata, + canRenameDisplayName = canRenameDisplayName, + canRestoreEmbeddedMetadata = canEditEmbeddedMetadata, + onDismiss = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = null + }, onSave = { updated -> updateBookMetadata(updated) - bookEditDialogFor = null + bookInfoInitiallyEditing = false + bookInfoDialogFor = null + }, + onRestore = { restored -> + updateBookMetadata(restored) + bookInfoInitiallyEditing = false + bookInfoDialogFor = null } ) } @@ -1883,6 +2972,18 @@ private data class DesktopDropImportState( val hasFilePayload: Boolean = false ) +private fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean { + return title != updated.title || + author != updated.author || + description != updated.description || + seriesName != updated.seriesName || + seriesIndex != updated.seriesIndex +} + +private fun String.toDesktopSafeFileName(): String { + return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } +} + @Composable private fun DesktopFileDropTarget( window: Component?, @@ -2087,12 +3188,74 @@ private fun BookItem.withDesktopImportMetadata( } else { author }, + description = if (shouldApplyText(description, original?.description)) { + enriched.description ?: description + } else { + description + }, + seriesName = if (shouldApplyText(seriesName, original?.seriesName)) { + enriched.seriesName ?: seriesName + } else { + seriesName + }, + seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) { + enriched.seriesIndex ?: seriesIndex + } else { + seriesIndex + }, + originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title, + originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author, + originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName, + originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex, + originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description, fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: fileContentModifiedTimestamp, coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath, folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed ) } +internal fun resolvedDesktopReaderSettings( + book: BookItem, + readerDefaultSettings: ReaderSettings +): ReaderSettings { + return book.readerSettings ?: readerDefaultSettings +} + +@Composable +private fun DesktopReaderOpeningScreen( + opening: DesktopReaderOpening, + onReturnToLibrary: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + text = "Opening ${opening.title}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Text( + text = opening.formatLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + TextButton(onClick = onReturnToLibrary) { + Text("Return to library") + } + } + } +} + @Composable private fun HomeScreen( state: SharedReaderScreenState, @@ -2110,7 +3273,8 @@ private fun HomeScreen( onCloseTab: (BookItem) -> Unit, onCloseAllTabs: () -> Unit, onRecentLimitChange: (Int) -> Unit, - onTogglePinned: (BookItem) -> Unit + onTogglePinned: (BookItem) -> Unit, + onOpenSettings: () -> Unit ) { SharedHomeScreen( state = state, @@ -2128,7 +3292,9 @@ private fun HomeScreen( onCloseTab = onCloseTab, onCloseAllTabs = onCloseAllTabs, onRecentLimitChange = onRecentLimitChange, - onTogglePinned = onTogglePinned + onTogglePinned = onTogglePinned, + onOpenSettings = onOpenSettings, + showActiveTabs = false ) } @@ -2153,6 +3319,8 @@ private fun LibraryScreen( onTagSelectedBooks: () -> Unit, onAddSelectedBooksToShelf: () -> Unit, onImportFolder: () -> Unit, + onSyncFolderMetadata: () -> Unit, + onScanFolders: () -> Unit, onTogglePinned: (BookItem) -> Unit ) { SharedLibraryScreen( @@ -2175,7 +3343,10 @@ private fun LibraryScreen( onTagSelectedBooks = onTagSelectedBooks, onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, onImportFolder = onImportFolder, - onTogglePinned = onTogglePinned + onSyncFolderMetadata = onSyncFolderMetadata, + onScanFolders = onScanFolders, + onTogglePinned = onTogglePinned, + useImportEmptyStateWhenLibraryEmpty = true ) } @@ -2242,7 +3413,7 @@ private fun SmartShelfDialog( modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - OutlinedTextField( + SharedStableOutlinedTextField( value = name, onValueChange = { name = it }, label = { Text("Shelf name") }, @@ -2298,12 +3469,13 @@ private fun SmartShelfDialog( } } } - OutlinedTextField( + SharedStableOutlinedTextField( value = draft.value, onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } }, label = { Text(draft.field.valueLabel()) }, singleLine = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + selectionKey = index ) } } @@ -2409,9 +3581,16 @@ private val DesktopPdfAnnotationTools = listOf( PdfInkTool.ERASER ) +private enum class DesktopPdfInspectorTab(val title: String) { + VIEW("View"), + MARKUP("Markup"), + ASSIST("Assist") +} + private data class DesktopPdfThemeStyle( val theme: ReaderTheme, val viewerBackgroundColor: Color, + val pageBackgroundColor: Color, val colorFilter: ColorFilter?, val textureBitmap: ImageBitmap?, val textureAlpha: Float, @@ -2425,7 +3604,7 @@ private fun DesktopPdfThemedPageImage( themeStyle: DesktopPdfThemeStyle, modifier: Modifier = Modifier ) { - Box(modifier = modifier) { + Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { Image( bitmap = bitmap, contentDescription = contentDescription, @@ -2463,16 +3642,12 @@ private fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { private fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { val theme = toDesktopPdfTheme() - val viewerBackground = when (theme.id) { - "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black - "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White - else -> theme.backgroundColor.takeIf { it.isSpecified } - ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black - } + val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode) val isDarkTexture = theme.isDark || theme.id == "reverse" return DesktopPdfThemeStyle( theme = theme, - viewerBackgroundColor = viewerBackground, + viewerBackgroundColor = pageBackground, + pageBackgroundColor = pageBackground, colorFilter = theme.toDesktopPdfColorFilter(), textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId), textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f), @@ -2536,6 +3711,41 @@ private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? { } } +@Composable +private fun DesktopPdfInspectorSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } +} + +@Composable +private fun DesktopPdfVisualOptionSwitch( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + private object DesktopReaderTextures { private val bytesCache = mutableMapOf() private val dataUriCache = mutableMapOf() @@ -2618,9 +3828,7 @@ private object DesktopReaderTextures { } private fun readerTextureDirectory(): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath - return File(baseDir, "Episteme/reader_textures") + return File(desktopUserDataRoot(), "reader_textures") } } @@ -2641,6 +3849,12 @@ private fun Long.toComposeColor(): Color { private val PdfInkTool.isDesktopHighlighter: Boolean get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND +private val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean + get() = kind == PdfAnnotationKind.HIGHLIGHT && + text.isNotBlank() && + rangeStartIndex != null && + rangeEndIndex != null + private fun List.withDesktopPdfDragPoint( point: Offset, canvasSize: IntSize, @@ -2663,50 +3877,330 @@ private fun List.withDesktopPdfDragPoint( return this + nextPoint } +internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { + if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f + val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) + return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() +} + +internal fun desktopPdfZoomTarget( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + factor: Float +): Float { + val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default + val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f + return zoomSpec.clamp(baseZoom * safeFactor) +} + +internal fun desktopPdfAnchoredScrollTarget( + currentScroll: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return currentScroll.coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredLazyItemScrollOffset( + itemOffset: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return (-itemOffset).coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + val offsetWithinItem = anchor - itemOffset + return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredPageScrollDelta( + viewportRootOffset: Offset, + oldPageRootOffset: Offset, + currentPageRootOffset: Offset, + anchor: Offset, + oldZoom: Float, + newZoom: Float +): IntOffset? { + if ( + !anchor.x.isFinite() || + !anchor.y.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return null + } + val rootAnchor = viewportRootOffset + anchor + val oldPageLocal = rootAnchor - oldPageRootOffset + val zoomRatio = newZoom / oldZoom + val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) + val desiredPageRoot = rootAnchor - newPageLocal + val delta = currentPageRootOffset - desiredPageRoot + return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) +} + +internal fun desktopPdfPaginationFirstRenderScale( + requestedScale: Float, + hasPageRender: Boolean, + isOpeningRender: Boolean = false +): Float { + if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { + return requestedScale + } + return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) +} + +private data class DesktopPdfZoomPreview( + val baseZoom: Float, + val zoom: Float, + val anchor: Offset?, + val displayMode: PdfDisplayMode, + val pageIndex: Int? +) + +private data class DesktopPdfCachedPageRender( + val render: DesktopPdfPageRender, + val scale: Float +) + +internal fun desktopPdfZoomPreviewPivotFraction( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + pageCanvasSize: IntSize +): Offset? { + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + val pageAnchor = viewportRootOffset + anchor - pageRootOffset + if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null + return Offset( + x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), + y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) + ) +} + +internal fun desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + previewScale: Float +): Offset? { + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + if (!previewScale.isFinite() || previewScale <= 0f) return null + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null + return Offset( + x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), + y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) + ) +} + +private fun Modifier.desktopPdfZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + pageCanvasSize: IntSize +): Modifier { + val activePreview = preview ?: return this + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this + val transformOrigin = activePreview.anchor?.let { anchor -> + desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + pageCanvasSize = pageCanvasSize + )?.let { pivot -> + TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) + } ?: TransformOrigin.Center + } ?: TransformOrigin.Center + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + this.transformOrigin = transformOrigin + } +} + +private fun Modifier.desktopPdfDocumentZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset +): Modifier { + val activePreview = preview ?: return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this + val translation = activePreview.anchor?.let { anchor -> + desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + previewScale = previewScale + ) + } ?: Offset.Zero + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = translation.x + translationY = translation.y + transformOrigin = TransformOrigin(0f, 0f) + } +} + +@Composable +private fun Modifier.desktopPdfZoomGestures( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit +): Modifier { + val latestZoom by rememberUpdatedState(currentZoom) + val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) + return this.pointerInput(zoomSpec) { + var gestureZoom = latestZoom + var appliedGestureZoom = latestZoom + var lastZoomEventAt = 0L + var lastAppliedZoomAt = 0L + fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { + if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { + gestureZoom = latestZoom + appliedGestureZoom = latestZoom + lastAppliedZoomAt = 0L + } + val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) + gestureZoom = newZoom + lastZoomEventAt = eventTime + val shouldApplyNow = lastAppliedZoomAt == 0L || + eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || + newZoom == zoomSpec.min || + newZoom == zoomSpec.max + if (shouldApplyNow && newZoom != appliedGestureZoom) { + latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) + appliedGestureZoom = newZoom + lastAppliedZoomAt = eventTime + } + } + + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L + if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { + val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> + total + change.scrollDelta + } + val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x + val factor = desktopPdfScrollZoomFactor(zoomDelta) + if (abs(factor - 1f) > 0.0001f) { + applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) + event.changes.forEach { it.consume() } + } + continue + } + + val pressedPointers = event.changes.count { it.pressed } + if (pressedPointers > 1) { + val zoomChange = event.calculateZoom() + if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { + val centroid = event.calculateCentroid(useCurrent = false) + val anchor = if (centroid == Offset.Unspecified) { + event.changes.firstOrNull { it.pressed }?.position + } else { + centroid + } + applyZoomFactor(zoomChange, eventTime, anchor) + } + event.changes.forEach { it.consume() } + } + } + } + } +} + @Composable private fun PdfReaderScreen( document: DesktopPdfDocument, initialPageIndex: Int, + initialViewport: SharedPdfReaderViewport? = null, initialReaderSettings: ReaderSettings? = null, - onOpenPdf: () -> Unit, - onOpenBook: () -> Unit, - onPageStateChange: (pageIndex: Int, progress: Float) -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + onFullscreenChange: (Boolean) -> Unit = {}, + onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit, onReaderSettingsChange: (ReaderSettings) -> Unit = {}, + pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), + onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, customTextureIds: List = emptyList(), onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, onLocalSidecarsChanged: () -> Unit = {}, aiByokSettings: ReaderAiByokSettings, aiAdapter: DesktopByokAiAdapter, - ttsAdapter: DesktopGeminiCloudTtsAdapter + ttsAdapter: DesktopGeminiCloudTtsAdapter, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard ) { - val zoomSpec = remember { PdfZoomSpec() } + val zoomSpec = remember { DesktopPdfZoomSpec } + val restoredInitialViewport = remember(document.path, initialViewport) { + initialViewport?.sanitized(document.pageCount, zoomSpec) + } var pdfReaderSettings by remember(document.path) { mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings()) } var pdfState by remember(document.path) { - val defaultTool = PdfInkTool.PEN - val defaultToolConfig = SharedPdfAnnotationDefaults.configFor(defaultTool) mutableStateOf( SharedPdfReaderState.initial( pageCount = document.pageCount, - initialPageIndex = initialPageIndex, + initialPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, zoomSpec = zoomSpec ).copy( - isTextSelectionMode = true, - selectedTool = defaultTool, - selectedColorArgb = defaultToolConfig.colorArgb, - strokeWidth = defaultToolConfig.strokeWidth + displayMode = restoredInitialViewport?.displayMode ?: DesktopDefaultPdfDisplayMode, + zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default) ) ) } var renderedPage by remember(document.path) { mutableStateOf(null) } + var renderedPageIndex by remember(document.path) { mutableStateOf(null) } + var renderedPageScale by remember(document.path) { mutableStateOf(null) } var renderError by remember(document.path) { mutableStateOf(null) } var isRendering by remember(document.path) { mutableStateOf(false) } var renderJob by remember(document.path) { mutableStateOf(null) } + val zoomAnchorJob = remember(document.path) { AtomicReference(null) } + val zoomCommitJob = remember(document.path) { AtomicReference(null) } + var pdfZoomPreview by remember(document.path) { mutableStateOf(null) } var activeTextDraft by remember(document.path) { mutableStateOf(null) } var textStyleConfig by remember(document.path) { mutableStateOf(SharedPdfTextStyleConfig()) } var pageCanvasSize by remember(document.path) { mutableStateOf(IntSize.Zero) } + var pdfZoomViewportRootOffset by remember(document.path) { mutableStateOf(Offset.Zero) } + var paginatedPageRootOffset by remember(document.path) { mutableStateOf(Offset.Zero) } + val verticalPageRootOffsets = remember(document.path) { mutableStateMapOf() } + val paginatedRenderCache = remember(document.path) { mutableStateMapOf() } var activeStroke by remember(document.path, pdfState.pageIndex) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(document.path, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf(null) } var isHighlighterSnapEnabled by remember(document.path) { mutableStateOf(false) } var selectionStartIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } var selectionEndIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } @@ -2714,8 +4208,11 @@ private fun PdfReaderScreen( var selectionEndHit by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } var textSelection by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } var selectionMenuOffset by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } var pageScrubPreview by remember(document.path) { mutableStateOf(null) } var pageScrubStartPage by remember(document.path) { mutableStateOf(null) } + var showPdfZoomIndicator by remember(document.path) { mutableStateOf(false) } + var isPdfZoomIndicatorInitialized by remember(document.path) { mutableStateOf(false) } var jumpHistory by remember(document.path) { mutableStateOf(SharedPdfJumpHistory()) } var externalLinkDialogUrl by remember(document.path) { mutableStateOf(null) } var pdfExtrasState by remember(document.path) { @@ -2736,6 +4233,18 @@ private fun PdfReaderScreen( val clipboardManager = LocalClipboardManager.current val density = LocalDensity.current val pdfScope = rememberCoroutineScope() + var isFullscreen by remember(document.path) { mutableStateOf(false) } + val currentPdfFullscreen by rememberUpdatedState(isFullscreen) + val currentOnPdfFullscreenChange by rememberUpdatedState(onFullscreenChange) + DisposableEffect(document.path) { + onDispose { + zoomCommitJob.getAndSet(null)?.cancel() + zoomAnchorJob.getAndSet(null)?.cancel() + if (currentPdfFullscreen) { + currentOnPdfFullscreenChange(false) + } + } + } var isRichTextMode by remember(document.path) { mutableStateOf(false) } var isRichTextLoaded by remember(document.path) { mutableStateOf(false) } val richTextController = remember(document.path) { @@ -2765,21 +4274,46 @@ private fun PdfReaderScreen( } ) } - val pageVerticalScrollState = rememberScrollState() - val pageHorizontalScrollState = rememberScrollState() - val verticalListState = rememberLazyListState(initialFirstVisibleItemIndex = pdfState.pageIndex) + val pageVerticalScrollState = rememberScrollState( + initial = restoredInitialViewport?.paginatedVerticalScrollOffset ?: 0 + ) + val pageHorizontalScrollState = rememberScrollState( + initial = restoredInitialViewport?.horizontalScrollOffset ?: 0 + ) + val verticalListState = rememberLazyListState( + initialFirstVisibleItemIndex = restoredInitialViewport + ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } + ?.verticalFirstPageIndex + ?: pdfState.pageIndex, + initialFirstVisibleItemScrollOffset = restoredInitialViewport + ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } + ?.verticalFirstPageScrollOffset + ?: 0 + ) + val pdfReaderFocusRequester = remember(document.path) { FocusRequester() } val currentTextSelection by rememberUpdatedState(textSelection) val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) + val currentPdfScale by rememberUpdatedState(pdfState.zoom) + val currentPdfDisplayMode by rememberUpdatedState(pdfState.displayMode) + + LaunchedEffect(isFullscreen, document.path) { + repeat(if (isFullscreen) 4 else 1) { attempt -> + delay(if (attempt == 0) 80L else 120L) + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } fun clearPdfInteractionState() { activeStroke = emptyList() + eraserPosition = null selectionStartIndex = null selectionEndIndex = null selectionStartHit = null selectionEndHit = null textSelection = null selectionMenuOffset = null + activeSelectionHandle = null } fun dispatchPdf(action: SharedPdfReaderAction) { @@ -2791,6 +4325,11 @@ private fun PdfReaderScreen( } } + fun setPdfFullscreen(enabled: Boolean) { + isFullscreen = enabled + onFullscreenChange(enabled) + } + fun updatePdfReaderSettings(settings: ReaderSettings) { val nextSettings = settings.toDesktopPdfReaderSettings() pdfReaderSettings = nextSettings @@ -2913,26 +4452,267 @@ private fun PdfReaderScreen( SharedPdfRichTextLog.d( "desktop.tool.select tool=$tool richMode=$isRichTextMode page=${pdfState.pageIndex}" ) + val previousTool = pdfState.selectedTool deactivateRichTextMode() if (tool != PdfInkTool.TEXT) { commitActiveTextDraft() } - if (tool == PdfInkTool.TEXT && pdfState.isTextSelectionMode) { + if (pdfState.isTextSelectionMode) { dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) clearPdfInteractionState() } - dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) + if (previousTool != tool) { + dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) + } + if (tool.isDesktopHighlighter && previousTool != tool) { + pdfHighlighterPalette.sanitized().colors.firstOrNull()?.let { colorArgb -> + dispatchPdf(SharedPdfReaderAction.ColorSelected(colorArgb)) + } + } } val pageIndex = pdfState.pageIndex val scale = pdfState.zoom val displayMode = pdfState.displayMode + val zoomControlScale = pdfZoomPreview?.zoom ?: scale + val shouldShowPdfZoomIndicator = abs(zoomControlScale - 1f) > 0.001f + + LaunchedEffect(zoomControlScale, document.path) { + if (!isPdfZoomIndicatorInitialized) { + isPdfZoomIndicatorInitialized = true + showPdfZoomIndicator = false + return@LaunchedEffect + } + if (shouldShowPdfZoomIndicator) { + showPdfZoomIndicator = true + delay(1_500) + showPdfZoomIndicator = false + } else { + showPdfZoomIndicator = false + } + } + + fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat() + } + ?: verticalListState.layoutInfo.visibleItemsInfo.minByOrNull { item -> + when { + anchor.y < item.offset.toFloat() -> item.offset.toFloat() - anchor.y + anchor.y > (item.offset + item.size).toFloat() -> anchor.y - (item.offset + item.size).toFloat() + else -> 0f + } + } + + LaunchedEffect(scale, displayMode, pageIndex) { + val preview = pdfZoomPreview ?: return@LaunchedEffect + if ( + preview.displayMode != displayMode || + (preview.pageIndex != pageIndex && displayMode == PdfDisplayMode.PAGINATION) || + abs(preview.baseZoom - scale) > 0.0001f + ) { + pdfZoomPreview = null + zoomCommitJob.getAndSet(null)?.cancel() + } + } + + fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + val activePageIndex = currentPdfPageIndex + val activeDisplayMode = currentPdfDisplayMode + logPdfZoomPerf { + "commit_start mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "renderPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "renderJobActive=${renderJob?.isActive == true}" + } + pdfZoomPreview = null + val viewportRootOffsetAtZoomStart = pdfZoomViewportRootOffset + val pageRootOffsetAtZoomStart = paginatedPageRootOffset + val targetHorizontalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom) + } + val targetVerticalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom) + } + val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) { + verticalZoomAnchorItem(anchor) + ?.let { item -> + val fallbackOffset = desktopPdfAnchoredLazyItemScrollOffset( + itemOffset = item.offset, + anchor = anchor.y, + oldZoom = oldZoom, + newZoom = newZoom + ) + val pageRootOffset = verticalPageRootOffsets[item.index] + Triple(item.index, fallbackOffset, pageRootOffset) + } + } else { + null + } + dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom)) + if (anchor != null) { + val nextAnchorJob = pdfScope.launch { + withFrameNanos { } + when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> { + suspend fun correctPageAnchor() { + val pageDelta = desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffsetAtZoomStart, + oldPageRootOffset = pageRootOffsetAtZoomStart, + currentPageRootOffset = paginatedPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) + if (pageDelta != null) { + if (abs(pageDelta.x) > 1) { + pageHorizontalScrollState.scrollTo( + (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( + 0 + ) + ) + } + if (abs(pageDelta.y) > 1) { + pageVerticalScrollState.scrollTo( + (pageVerticalScrollState.value + pageDelta.y).coerceAtLeast( + 0 + ) + ) + } + } else if (targetHorizontalScroll != null && targetVerticalScroll != null) { + pageHorizontalScrollState.scrollTo(targetHorizontalScroll) + pageVerticalScrollState.scrollTo(targetVerticalScroll) + } + } + correctPageAnchor() + withFrameNanos { } + correctPageAnchor() + } + + PdfDisplayMode.VERTICAL_SCROLL -> { + suspend fun correctVerticalAnchor() { + val oldPageRootOffset = targetVerticalItem?.third + val currentPageRootOffset = + targetVerticalItem?.first?.let { verticalPageRootOffsets[it] } + val pageDelta = + if (oldPageRootOffset != null && currentPageRootOffset != null) { + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffsetAtZoomStart, + oldPageRootOffset = oldPageRootOffset, + currentPageRootOffset = currentPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) + } else { + null + } + if (pageDelta != null) { + if (abs(pageDelta.x) > 1) { + pageHorizontalScrollState.scrollTo( + (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( + 0 + ) + ) + } + if (abs(pageDelta.y) > 1) { + verticalListState.scrollBy(pageDelta.y.toFloat()) + } + } else { + targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } + targetVerticalItem?.let { (itemIndex, scrollOffset, _) -> + verticalListState.scrollToItem(itemIndex, scrollOffset) + } + } + } + correctVerticalAnchor() + withFrameNanos { } + correctVerticalAnchor() + } + } + } + zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel() + } + } + + fun previewAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + val activePageIndex = currentPdfPageIndex + val activeScale = currentPdfScale + val activeDisplayMode = currentPdfDisplayMode + logPdfZoomPerf { + "preview mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "hasRender=${renderedPage != null && renderedPageIndex == activePageIndex} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" + } + val existingPreview = pdfZoomPreview + val baseZoom = existingPreview + ?.takeIf { it.displayMode == activeDisplayMode && it.baseZoom.isFinite() && it.baseZoom > 0f } + ?.baseZoom + ?: oldZoom.takeIf { it.isFinite() && it > 0f } + ?: activeScale + val previewPageIndex = when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> activePageIndex + PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex + } + pdfZoomPreview = DesktopPdfZoomPreview( + baseZoom = baseZoom, + zoom = newZoom, + anchor = anchor, + displayMode = activeDisplayMode, + pageIndex = previewPageIndex + ) + val nextCommitJob = pdfScope.launch { + delay(DesktopPdfZoomCommitDebounceMillis) + val preview = pdfZoomPreview ?: return@launch + pdfZoomPreview = null + applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor) + } + zoomCommitJob.getAndSet(nextCommitJob)?.cancel() + if ( + activeDisplayMode == PdfDisplayMode.PAGINATION && + renderedPage != null && + renderedPageIndex == activePageIndex + ) { + renderJob?.cancel() + } + } + + fun cancelPendingPdfZoomPreview() { + pdfZoomPreview = null + zoomCommitJob.getAndSet(null)?.cancel() + } + + fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) { + paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale) + val activePageIndex = currentPdfPageIndex + val keepRange = + (activePageIndex - DesktopPdfPaginationRenderCacheRadius)..(activePageIndex + DesktopPdfPaginationRenderCacheRadius) + val evictedPages = paginatedRenderCache.keys + .filter { it !in keepRange } + evictedPages.forEach { paginatedRenderCache.remove(it) } + logPdfZoomPerf { + "cache_put page=${page + 1} scale=${renderScale.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" + } + } + + LaunchedEffect(document.path, pageIndex, displayMode) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + val searchQuery = pdfState.searchQuery + val isPdfSearchActive = pdfState.isSearchActive + val showPdfSearchResultsPanel = pdfState.showSearchResultsPanel val activeSearchIndex = pdfState.activeSearchResultIndex val searchHighlightMode = pdfState.searchHighlightMode val selectedTool = pdfState.selectedTool val selectedColor = pdfState.selectedColorArgb val strokeWidth = pdfState.strokeWidth + val pdfHighlighterColors = pdfHighlighterPalette.sanitized().colors val isTextSelectionMode = pdfState.isTextSelectionMode val bookmarks = pdfState.bookmarks val selectedAnnotationId = pdfState.selectedAnnotationId @@ -2940,6 +4720,34 @@ private fun PdfReaderScreen( val canGoPrevious = pdfState.canGoPrevious val canGoNext = pdfState.canGoNext val progressPercent = pdfState.progressPercent + val latestOnPageStateChange by rememberUpdatedState(onPageStateChange) + + fun pdfViewportSnapshot(): SharedPdfReaderViewport { + val state = pdfState + return SharedPdfReaderViewport( + pageIndex = state.pageIndex, + displayMode = state.displayMode, + zoom = pdfZoomPreview?.zoom ?: state.zoom, + horizontalScrollOffset = pageHorizontalScrollState.value, + paginatedVerticalScrollOffset = pageVerticalScrollState.value, + verticalFirstPageIndex = verticalListState.firstVisibleItemIndex, + verticalFirstPageScrollOffset = verticalListState.firstVisibleItemScrollOffset + ).sanitized(document.pageCount, zoomSpec) + } + + fun pdfProgressPercentFor(pageIndex: Int): Float { + return ((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f + } + + var latestPdfViewport by remember(document.path) { + mutableStateOf(restoredInitialViewport ?: pdfViewportSnapshot()) + } + + fun persistPdfViewport(viewport: SharedPdfReaderViewport = pdfViewportSnapshot()) { + latestPdfViewport = viewport + latestOnPageStateChange(viewport.pageIndex, pdfProgressPercentFor(viewport.pageIndex), viewport) + } + val pdfThemeStyle = remember(pdfReaderSettings, displayMode) { pdfReaderSettings.toDesktopPdfThemeStyle(displayMode) } @@ -2957,6 +4765,7 @@ private fun PdfReaderScreen( val selectedAnnotation = remember(annotations, selectedAnnotationId) { annotations.firstOrNull { it.id == selectedAnnotationId } } + val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight } val sortedAnnotations = remember(annotations) { annotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.createdAt }) } @@ -2973,6 +4782,24 @@ private fun PdfReaderScreen( } val activePdfTtsChunk = pdfExtrasState.cloudTts.progress.currentChunk + LaunchedEffect(selectedTool) { + activeStroke = emptyList() + eraserPosition = null + } + + fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) { + val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOf(selectedColor) + val sanitizedPalette = nextPalette.sanitized() + onPdfHighlighterPaletteChange(sanitizedPalette) + if (selectedTool.isDesktopHighlighter && selectedColor !in sanitizedPalette.colors) { + val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot) + ?: sanitizedPalette.colors.firstOrNull() + colorArgb?.let { nextSelectedColor -> + dispatchPdf(SharedPdfReaderAction.ColorSelected(nextSelectedColor)) + } + } + } + fun currentPdfTtsCacheSummary() = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) @@ -2981,6 +4808,19 @@ private fun PdfReaderScreen( onDismiss = { externalLinkDialogUrl = null } ) + val pdfPopupActive = + externalLinkDialogUrl != null || + selectedTextHighlight != null || + selectedEmbeddedAnnotation != null || + pdfExtrasState.aiResult.hasContent || + (textSelection != null && selectionMenuOffset != null) + LaunchedEffect(pdfPopupActive, document.path) { + if (!pdfPopupActive) { + delay(120L) + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + LaunchedEffect(aiByokSettings) { pdfExtrasState = pdfExtrasState.copy( cloudTts = pdfExtrasState.cloudTts.copy( @@ -3069,11 +4909,15 @@ private fun PdfReaderScreen( } indexedSearchPageCount = restoredPageCount isSearchIndexing = indexedSearchPageCount < document.pageCount + logPdfZoomPerf { + "search_index_restore indexed=$indexedSearchPageCount/${document.pageCount} active=$isSearchIndexing" + } withContext(Dispatchers.IO) { DesktopPdfium.indexSearchPages( document = document, onProgress = { indexed, _ -> indexedSearchPageCount = indexed + logPdfZoomPerf { "search_index_progress indexed=$indexed/${document.pageCount}" } }, shouldContinue = { isActive } ) @@ -3084,6 +4928,7 @@ private fun PdfReaderScreen( if (!isActive) return@LaunchedEffect indexedSearchPageCount = document.indexedSearchTextPageCount() isSearchIndexing = false + logPdfZoomPerf { "search_index_done indexed=$indexedSearchPageCount/${document.pageCount}" } } LaunchedEffect(document.path, searchQuery, indexedSearchPageCount) { @@ -3132,6 +4977,29 @@ private fun PdfReaderScreen( } } + fun updatePdfPageScrub(value: Float) { + if (pageScrubStartPage == null) { + pageScrubStartPage = pdfState.pageIndex + } + val targetPage = value.roundToInt().coerceIn(0, document.pageCount - 1) + pageScrubPreview = targetPage + goToPage(targetPage) + } + + fun finishPdfPageScrub() { + val startPage = pageScrubStartPage + val targetPage = pdfState.pageIndex + if (startPage != null) { + jumpHistory = jumpHistory.record( + currentPageIndex = startPage, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + } + pageScrubStartPage = null + pageScrubPreview = null + } + fun goBackInJumpHistory() { val targetPage = jumpHistory.backPage ?: return jumpHistory = jumpHistory.stepBack() @@ -3159,7 +5027,9 @@ private fun PdfReaderScreen( val url = it.normalizedExternalUrl() logPdfLink("activate_external fromPage=${pageIndex + 1} url=\"${url.logPreview()}\"") clearPdfInteractionState() - externalLinkDialogUrl = url + if (featurePolicy.externalLookup) { + externalLinkDialogUrl = url + } return } logPdfLink( @@ -3185,7 +5055,12 @@ private fun PdfReaderScreen( } } - fun highlightSelection(pageIndex: Int, selection: DesktopPdfTextSelection, canvasSize: IntSize) { + fun highlightSelection( + pageIndex: Int, + selection: DesktopPdfTextSelection, + canvasSize: IntSize, + colorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb + ) { val now = System.currentTimeMillis() val highlightBounds = DesktopPdfium.textRectsForRange( document = document, @@ -3226,13 +5101,14 @@ private fun PdfReaderScreen( bounds = highlightBounds.firstOrNull(), boundsList = highlightBounds, text = selection.text, - colorArgb = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb, + colorArgb = SharedPdfAndroidHighlightColors.nearestArgb(colorArgb), rangeStartIndex = selection.startIndex, rangeEndIndex = selection.endIndex, createdAt = now ) ) ) + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } fun clearSelection() { @@ -3242,23 +5118,11 @@ private fun PdfReaderScreen( selectionStartHit = null selectionEndHit = null selectionMenuOffset = null - } - - fun highlightCurrentSelection() { - val selection = textSelection ?: return - highlightSelection(pageIndex, selection, pageCanvasSize) - clearSelection() - } - - fun searchSelection(selection: DesktopPdfTextSelection) { - dispatchPdf(SharedPdfReaderAction.SearchChanged(selection.text.take(120))) - } - - fun translateSelection(selection: DesktopPdfTextSelection) { - openExternalUrl(externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, selection.text)) + activeSelectionHandle = null } fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) { + if (!featurePolicy.externalLookup) return val normalizedText = text.trim() if (normalizedText.isBlank()) return openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) @@ -3390,9 +5254,8 @@ private fun PdfReaderScreen( fun startPdfCloudTts(readScope: ReaderTtsReadScope) { val settings = aiByokSettings.sanitized() - val startPageIndex = pageIndex logDesktopTts( - "pdf_sequence_toggle scope=${readScope.name} startPage=${startPageIndex + 1} " + + "pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " + "isPlaying=${pdfExtrasState.cloudTts.isPlaying} isLoading=${pdfExtrasState.cloudTts.isLoading} " + "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + "available=${ttsAdapter.isAvailable}" @@ -3426,9 +5289,9 @@ private fun PdfReaderScreen( var completedChunkCount = 0 runCatching { val ttsChunks = withContext(Dispatchers.IO) { - pdfTtsChunksForScope(readScope, startPageIndex) + pdfTtsChunksForScope(readScope, pageIndex) .filter { it.text.isNotBlank() } - .withTtsReplacements(state.readerTtsReplacementPreferences, document.path) + .withTtsReplacements(ttsReplacementPreferences, document.path) } if (ttsChunks.isEmpty()) { logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") @@ -3466,12 +5329,12 @@ private fun PdfReaderScreen( }.onFailure { error -> logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"") if (error !is kotlinx.coroutines.CancellationException && error.message != noTextMessage) error.printStackTrace() - if (error is kotlinx.coroutines.CancellationException) { - pdfExtrasState = pdfExtrasState.copy( + pdfExtrasState = if (error is kotlinx.coroutines.CancellationException) { + pdfExtrasState.copy( cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped") ) } else { - pdfExtrasState = pdfExtrasState.copy( + pdfExtrasState.copy( cloudTts = pdfCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.") ) } @@ -3522,7 +5385,7 @@ private fun PdfReaderScreen( pageIndex = pageIndex, chapterIndex = 0, chapterTitle = "Page ${pageIndex + 1}" - ).withTtsReplacements(state.readerTtsReplacementPreferences, document.path) + ).withTtsReplacements(ttsReplacementPreferences, document.path) if (selectionChunks.isEmpty()) { pdfExtrasState = pdfExtrasState.copy( cloudTts = pdfExtrasState.cloudTts.copy( @@ -3592,11 +5455,23 @@ private fun PdfReaderScreen( dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotationId)) } + fun goToAnnotation(annotation: SharedPdfAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + selectedEmbeddedAnnotationId = null + goToPage(annotation.pageIndex, recordJump = true) + } + fun selectAnnotation(annotation: SharedPdfAnnotation?) { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id)) annotation?.let { goToPage(it.pageIndex, recordJump = true) } } + fun goToEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + selectedEmbeddedAnnotationId = null + goToPage(annotation.pageIndex, recordJump = true) + } + fun selectEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation?) { selectedEmbeddedAnnotationId = annotation?.id annotation?.let { goToPage(it.pageIndex, recordJump = true) } @@ -3630,16 +5505,78 @@ private fun PdfReaderScreen( jumpHistory = jumpHistory.pruned(document.pageCount) } - LaunchedEffect(document.path, pageIndex, progressPercent) { - onPageStateChange(pageIndex, progressPercent) + LaunchedEffect(document.path, document.pageCount) { + snapshotFlow { pdfViewportSnapshot() } + .distinctUntilChanged() + .collectLatest { viewport -> + latestPdfViewport = viewport + delay(DesktopPdfViewportPersistDebounceMillis) + persistPdfViewport(viewport) + } } + DisposableEffect(document.path) { + onDispose { + persistPdfViewport() + } + } + + var pendingInitialViewportRestore by remember(document.path) { mutableStateOf(restoredInitialViewport) } LaunchedEffect(document.path, displayMode) { if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) { + if (pendingInitialViewportRestore?.displayMode == PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect verticalListState.scrollToItem(pageIndex) } } + LaunchedEffect( + document.path, + pendingInitialViewportRestore, + displayMode, + renderedPageIndex, + renderedPageScale + ) { + val viewport = pendingInitialViewportRestore ?: return@LaunchedEffect + if (viewport.displayMode != displayMode) { + pendingInitialViewportRestore = null + return@LaunchedEffect + } + when (viewport.displayMode) { + PdfDisplayMode.PAGINATION -> { + if (renderedPageIndex != viewport.pageIndex) return@LaunchedEffect + withFrameNanos { } + pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) + pageVerticalScrollState.scrollTo(viewport.paginatedVerticalScrollOffset) + pendingInitialViewportRestore = null + latestPdfViewport = viewport + } + + PdfDisplayMode.VERTICAL_SCROLL -> { + withFrameNanos { } + verticalListState.scrollToItem( + viewport.verticalFirstPageIndex, + viewport.verticalFirstPageScrollOffset + ) + pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) + pendingInitialViewportRestore = null + latestPdfViewport = viewport + } + } + } + + fun selectPdfPanMode() { + SharedPdfRichTextLog.d( + "desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}" + ) + deactivateRichTextMode() + commitActiveTextDraft() + clearPdfInteractionState() + if (pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + } + dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) + } + LaunchedEffect(pdfExtrasState.autoScroll.sanitized(), pageIndex, canGoNext, displayMode) { val autoScroll = pdfExtrasState.autoScroll.sanitized() if (!autoScroll.enabled) return@LaunchedEffect @@ -3688,39 +5625,189 @@ private fun PdfReaderScreen( isRendering = false renderError = null renderedPage = null + renderedPageIndex = null + renderedPageScale = null return@LaunchedEffect } - renderJob = launch { - delay(90) + logPdfZoomPerf { + "render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + + "existingPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + + "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " + + "cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" + } + if (renderedPageIndex != pageIndex) { + paginatedRenderCache[pageIndex]?.let { cached -> + logPdfZoomPerf { + "cache_hit page=${pageIndex + 1} scale=${cached.scale.formatLogFloat()} " + + "bitmap=${cached.render.width}x${cached.render.height}" + } + renderedPage = cached.render + renderedPageIndex = pageIndex + renderedPageScale = cached.scale + renderError = null + isRendering = false + } + } + val hasPageRender = renderedPage != null && renderedPageIndex == pageIndex + if (!hasPageRender) { + logPdfZoomPerf { "cache_miss page=${pageIndex + 1}; showing spinner until first render" } + renderedPage = null + renderedPageIndex = null + renderedPageScale = null isRendering = true - renderError = null - val pageSize = document.pageSizes[pageIndex] + } + renderJob = launch { + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + renderError = "Failed to render page." + isRendering = false + return@launch + } val safeScale = zoomSpec.safeRenderScale( pageSize.width, pageSize.height, scale ) - val result = withContext(Dispatchers.IO) { - runCatching { - DesktopPdfium.renderPage(document, pageIndex, safeScale) + val isOpeningRender = paginatedRenderCache.isEmpty() && !hasPageRender + val firstRenderScale = desktopPdfPaginationFirstRenderScale( + requestedScale = safeScale, + hasPageRender = hasPageRender, + isOpeningRender = isOpeningRender + ) + logPdfZoomPerf { + "render_plan page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + + "hasRender=$hasPageRender opening=$isOpeningRender" + } + + suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean { + logPdfZoomPerf { + "render_scheduled page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " + + "hasPageRender=$hasPageRender" + } + delay(delayMillis) + if (showSpinner) { + isRendering = true + } + renderError = null + val startedAt = System.currentTimeMillis() + val result = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage(document, pageIndex, renderScale) + } + } + val elapsedMs = System.currentTimeMillis() - startedAt + if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || + currentPdfDisplayMode != PdfDisplayMode.PAGINATION + ) { + logPdfZoomPerf { + "render_stale page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " + + "currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode" + } + return false + } + result.getOrNull()?.let { render -> + cachePaginatedRender(pageIndex, renderScale, render) + renderedPage = render + renderedPageIndex = pageIndex + renderedPageScale = renderScale + } + renderError = result.exceptionOrNull()?.message + ?: if (renderedPage == null || renderedPageIndex != pageIndex) "Failed to render page." else null + logPdfZoomPerf { + "render_end page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " + + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" + } + renderedPage?.let { render -> + logPdfSelection( + "render page=${pageIndex + 1} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${renderScale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} " + + "pageSize=${pageSize.width.formatLogFloat()}x${pageSize.height.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} capped=${safeScale < zoomSpec.clamp( + scale + )}" + ) + } + isRendering = false + return result.isSuccess && renderedPageIndex == pageIndex + } + + suspend fun prefetchPage(pageToPrefetch: Int) { + if (pageToPrefetch !in 0 until document.pageCount) return + val cached = paginatedRenderCache[pageToPrefetch] + if ( + cached != null && + cached.scale >= DesktopPdfPaginationFastFirstRenderMaxScale - DesktopPdfRenderScaleTolerance + ) { + logPdfZoomPerf { + "prefetch_skip_cached page=${pageToPrefetch + 1} scale=${cached.scale.formatLogFloat()}" + } + return + } + val prefetchPageSize = document.pageSizes.getOrNull(pageToPrefetch) ?: return + val prefetchScale = zoomSpec.safeRenderScale( + prefetchPageSize.width, + prefetchPageSize.height, + DesktopPdfPaginationFastFirstRenderMaxScale + ) + logPdfZoomPerf { + "prefetch_start page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + + "current=${pageIndex + 1}" + } + val startedAt = System.currentTimeMillis() + val result = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage(document, pageToPrefetch, prefetchScale) + } + } + val elapsedMs = System.currentTimeMillis() - startedAt + if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || + currentPdfDisplayMode != PdfDisplayMode.PAGINATION || + pdfZoomPreview != null + ) { + logPdfZoomPerf { + "prefetch_stale page=${pageToPrefetch + 1} elapsedMs=$elapsedMs " + + "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + + "mode=$currentPdfDisplayMode preview=${pdfZoomPreview != null}" + } + return + } + result.getOrNull()?.let { render -> + cachePaginatedRender(pageToPrefetch, prefetchScale, render) + } + logPdfZoomPerf { + "prefetch_end page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + + "elapsedMs=$elapsedMs success=${result.isSuccess} " + + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" } } - if (pageIndex != pageIndex || scale != scale) { - return@launch - } - renderedPage = result.getOrNull() - renderError = result.exceptionOrNull()?.message - ?: if (renderedPage == null) "Failed to render page." else null - renderedPage?.let { render -> - logPdfSelection( - "render page=${pageIndex + 1} " + - "requestedScale=${scale.formatLogFloat()} safeScale=${safeScale.formatLogFloat()} " + - "pageSize=${pageSize.width.formatLogFloat()}x${pageSize.height.formatLogFloat()} " + - "bitmap=${render.width}x${render.height} capped=${safeScale < zoomSpec.clamp( - scale - )}" + + val existingScale = renderedPageScale + val needsFirstRender = !hasPageRender || + existingScale == null || + abs(existingScale - firstRenderScale) > DesktopPdfRenderScaleTolerance + if (needsFirstRender) { + renderAt( + renderScale = firstRenderScale, + delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L, + showSpinner = !hasPageRender ) } - isRendering = false + delay(DesktopPdfPaginationPrefetchDelayMillis) + if (currentPdfPageIndex == pageIndex && currentPdfScale == scale && + currentPdfDisplayMode == PdfDisplayMode.PAGINATION && + pdfZoomPreview == null + ) { + prefetchPage(pageIndex + 1) + prefetchPage(pageIndex - 1) + } } } @@ -3731,26 +5818,42 @@ private fun PdfReaderScreen( hasBookmarks = bookmarks.isNotEmpty(), hasAnnotations = sortedAnnotations.isNotEmpty(), hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), - searchActive = searchQuery.isNotBlank(), + searchActive = isPdfSearchActive || searchQuery.isNotBlank(), annotationEditing = activeTextDraft != null || selectedAnnotation != null || - selectedTool != PdfInkTool.PEN || - !isTextSelectionMode, + selectedTool != PdfInkTool.NONE || + isTextSelectionMode, richTextEditing = isRichTextMode, loading = isRendering || isSearchIndexing, errorMessage = renderError, extrasState = pdfExtrasState, - aiAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable + aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + cloudTtsAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().isCloudTtsAvailable, + externalLookupAvailable = featurePolicy.externalLookup ) fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { if (event.type != KeyEventType.KeyDown) return false + if (isFullscreen && event.key == Key.Escape) { + setPdfFullscreen(false) + return true + } val isEditingTextAnnotation = activeTextDraft != null || (selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT) if ((isEditingTextAnnotation || isRichTextMode) && !event.isCtrlPressed) { return false } + fun scrollVertically(delta: Float): Boolean { + pdfScope.launch { + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + verticalListState.scrollBy(delta) + } else { + pageVerticalScrollState.scrollBy(delta) + } + } + return true + } return when { event.key == Key.DirectionLeft -> { goToPage(pageIndex - 1) @@ -3760,14 +5863,8 @@ private fun PdfReaderScreen( goToPage(pageIndex + 1) true } - event.key == Key.DirectionUp && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> { - goToPage(pageIndex - 1) - true - } - event.key == Key.DirectionDown && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> { - goToPage(pageIndex + 1) - true - } + event.key == Key.DirectionUp -> scrollVertically(-96f) + event.key == Key.DirectionDown -> scrollVertically(96f) event.key == Key.PageUp -> { goToPage(pageIndex - 1) true @@ -3784,11 +5881,17 @@ private fun PdfReaderScreen( goToPage(document.pageCount - 1) true } + event.isCtrlPressed && event.key == Key.F -> { + dispatchPdf(SharedPdfReaderAction.SearchOpened) + true + } event.isCtrlPressed && event.key == Key.Equals -> { + cancelPendingPdfZoomPreview() dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) true } event.isCtrlPressed && event.key == Key.Minus -> { + cancelPendingPdfZoomPreview() dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) true } @@ -3796,8 +5899,95 @@ private fun PdfReaderScreen( } } + fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED) return false + if (isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + setPdfFullscreen(false) + return true + } + val isEditingTextAnnotation = + activeTextDraft != null || + (selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT) + if ((isEditingTextAnnotation || isRichTextMode) && !event.isControlDown) { + return false + } + fun scrollVertically(delta: Float): Boolean { + pdfScope.launch { + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + verticalListState.scrollBy(delta) + } else { + pageVerticalScrollState.scrollBy(delta) + } + } + return true + } + return when (event.keyCode) { + AwtKeyEvent.VK_LEFT -> { + goToPage(pageIndex - 1) + true + } + AwtKeyEvent.VK_RIGHT -> { + goToPage(pageIndex + 1) + true + } + AwtKeyEvent.VK_UP -> scrollVertically(-96f) + AwtKeyEvent.VK_DOWN -> scrollVertically(96f) + AwtKeyEvent.VK_PAGE_UP -> { + goToPage(pageIndex - 1) + true + } + AwtKeyEvent.VK_PAGE_DOWN -> { + goToPage(pageIndex + 1) + true + } + AwtKeyEvent.VK_HOME -> { + goToPage(0) + true + } + AwtKeyEvent.VK_END -> { + goToPage(document.pageCount - 1) + true + } + AwtKeyEvent.VK_F -> { + if (!event.isControlDown) return false + dispatchPdf(SharedPdfReaderAction.SearchOpened) + true + } + AwtKeyEvent.VK_EQUALS, + AwtKeyEvent.VK_PLUS, + AwtKeyEvent.VK_ADD -> { + if (!event.isControlDown) return false + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) + true + } + AwtKeyEvent.VK_MINUS, + AwtKeyEvent.VK_SUBTRACT -> { + if (!event.isControlDown) return false + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) + true + } + else -> false + } + } + + DesktopReaderFullscreenKeyEffect( + enabled = isFullscreen && !pdfPopupActive, + onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + ) + + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PdfNavigationSidebar() { + val tabs = listOf("TOC", "Annotations", "Bookmarks", "Pages") + var selectedTabIndex by remember(document.path) { mutableStateOf(0) } + val navigationScope = rememberCoroutineScope() + val pdfTocParentIndices = remember(document.toc) { desktopPdfTocParentIndices(document.toc) } + var expandedPdfTocEntryIndices by remember(document.path, document.toc) { + mutableStateOf(pdfTocParentIndices) + } + Surface( modifier = Modifier .width(300.dp) @@ -3806,190 +5996,332 @@ private fun PdfReaderScreen( shape = RoundedCornerShape(8.dp), tonalElevation = 2.dp ) { - LazyColumn( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { - Text("Previous") - } - TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { - Text("Next") - } + Column(Modifier.fillMaxSize()) { + ScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + text = { + Text( + title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) } } - if (document.pageCount > 1) { - item { - Text( - "Page ${pageIndex + 1} of ${document.pageCount}", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Slider( - value = pageIndex.toFloat(), - onValueChange = { value -> - if (pageScrubStartPage == null) { - pageScrubStartPage = pdfState.pageIndex + + when (selectedTabIndex) { + 0 -> { + if (document.toc.isEmpty()) { + DesktopPdfNavigationEmpty("No table of contents") + } else { + val tocListState = rememberLazyListState() + val visibleTocItems by remember(document.toc) { + derivedStateOf { desktopVisiblePdfTocEntries(document.toc, expandedPdfTocEntryIndices) } + } + val currentOriginalIndex = remember(document.toc, pageIndex) { + document.toc.indexOfLast { it.pageIndex <= pageIndex } + .takeIf { it >= 0 } + ?: document.toc.indexOfFirst { it.pageIndex == pageIndex }.takeIf { it >= 0 } + } + fun locateCurrentTocEntry() { + val originalIndex = currentOriginalIndex ?: return + navigationScope.launch { + expandedPdfTocEntryIndices = expandedPdfTocEntryIndices + + desktopPdfTocAncestorIndices(document.toc, originalIndex) + repeat(4) { + val visibleIndex = visibleTocItems.indexOfFirst { it.first == originalIndex } + if (visibleIndex >= 0) { + tocListState.animateScrollToItem(visibleIndex) + return@launch + } + delay(30) + } } - val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) - pageScrubPreview = targetPage - goToPage(targetPage) - }, - onValueChangeFinished = { - val startPage = pageScrubStartPage - val targetPage = currentPdfPageIndex - if (startPage != null) { - jumpHistory = jumpHistory.record( - currentPageIndex = startPage, - targetPageIndex = targetPage, - pageCount = document.pageCount + } + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { expandedPdfTocEntryIndices = pdfTocParentIndices }) { + Text("Expand all") + } + TextButton(onClick = { expandedPdfTocEntryIndices = emptySet() }) { + Text("Collapse all") + } + TextButton(onClick = ::locateCurrentTocEntry, enabled = currentOriginalIndex != null) { + Text("Locate") + } + } + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = tocListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(tocListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + visibleTocItems, + key = { (index, entry) -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" } + ) { (originalIndex, entry) -> + val nextItem = document.toc.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel + val isExpanded = originalIndex in expandedPdfTocEntryIndices + DesktopPdfTocTreeItem( + entry = entry, + selected = originalIndex == currentOriginalIndex, + hasChildren = hasChildren, + isExpanded = isExpanded, + onToggleExpand = { + expandedPdfTocEntryIndices = if (isExpanded) { + expandedPdfTocEntryIndices - originalIndex + } else { + expandedPdfTocEntryIndices + originalIndex + } + }, + onClick = { goToPage(entry.pageIndex, recordJump = true) } + ) + } + } + SharedReaderVerticalScrollbar( + listState = tocListState, + modifier = Modifier.align(Alignment.CenterEnd) ) } - pageScrubStartPage = null - pageScrubPreview = null - }, - valueRange = 0f..(document.pageCount - 1).toFloat(), - steps = (document.pageCount - 2).coerceAtLeast(0) - ) + } + } } - } - item { - DesktopPdfJumpHistoryControls( - backPage = jumpHistory.backPage, - forwardPage = jumpHistory.forwardPage, - onBack = ::goBackInJumpHistory, - onForward = ::goForwardInJumpHistory, - onClear = { jumpHistory = jumpHistory.clear() } - ) - } - item { - val isBookmarked = bookmarks.any { it.pageIndex == pageIndex } - TextButton(onClick = { toggleBookmark(pageIndex) }) { - Text(if (isBookmarked) "Remove bookmark" else "Bookmark page") - } - } - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = searchQuery, - onValueChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, - label = { Text("Find in PDF") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - if (searchQuery.isNotBlank()) { - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - when { - isSearchIndexing -> { - val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}" - if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress" + 1 -> { + if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) { + DesktopPdfNavigationEmpty("No annotations yet") + } else { + val annotationsListState = rememberLazyListState() + var annotationMenuExpandedFor by remember { mutableStateOf(null) } + var embeddedAnnotationMenuExpandedFor by remember { mutableStateOf(null) } + var deleteAnnotationConfirmFor by remember { mutableStateOf(null) } + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = annotationsListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(annotationsListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier + .weight(1f) + .clickable { goToAnnotation(annotation) } + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text(annotation.desktopLabel(), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + annotation.note?.takeIf { it.isNotBlank() }?.let { note -> + Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + Box { + IconButton(onClick = { annotationMenuExpandedFor = annotation }) { + Icon(Icons.Default.MoreVert, contentDescription = "Annotation options") + } + DropdownMenu( + expanded = annotationMenuExpandedFor == annotation, + onDismissRequest = { annotationMenuExpandedFor = null } + ) { + DropdownMenuItem( + text = { Text(if (annotation.note.isNullOrBlank() && annotation.kind != PdfAnnotationKind.TEXT) "Add note" else "Edit") }, + onClick = { + annotationMenuExpandedFor = null + selectAnnotation(annotation) + } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + annotationMenuExpandedFor = null + deleteAnnotationConfirmFor = annotation + } + ) + } + } + } + } } - searchResults.isEmpty() -> "No matches" - activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}" - else -> "${searchResults.size} matches" - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) { - Text("Prev") + items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier + .weight(1f) + .clickable { goToEmbeddedAnnotation(annotation) } + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text(annotation.author.ifBlank { "PDF comment" }, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> + Text(contents, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + Box { + IconButton(onClick = { embeddedAnnotationMenuExpandedFor = annotation }) { + Icon(Icons.Default.MoreVert, contentDescription = "Comment options") + } + DropdownMenu( + expanded = embeddedAnnotationMenuExpandedFor == annotation, + onDismissRequest = { embeddedAnnotationMenuExpandedFor = null } + ) { + DropdownMenuItem( + text = { Text("Open comment") }, + onClick = { + embeddedAnnotationMenuExpandedFor = null + selectEmbeddedAnnotation(annotation) + } + ) + } + } + } + } + } + } + SharedReaderVerticalScrollbar( + listState = annotationsListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) } - TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) { - Text("Next") + deleteAnnotationConfirmFor?.let { annotation -> + AlertDialog( + onDismissRequest = { deleteAnnotationConfirmFor = null }, + title = { Text("Delete annotation?") }, + text = { Text("This removes the annotation from this PDF.") }, + confirmButton = { + TextButton( + onClick = { + deleteAnnotationConfirmFor = null + deleteAnnotation(annotation.id) + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteAnnotationConfirmFor = null }) { + Text("Cancel") + } + } + ) } } } - items(searchResults, key = { "nav_search_${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> - Surface( - color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { - goToSearchResult(searchResults.indexOf(result)) - } - ) { - Column(modifier = Modifier.padding(8.dp)) { - Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold) - Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + 2 -> { + if (bookmarks.isEmpty()) { + DesktopPdfNavigationEmpty("No bookmarks yet") + } else { + val bookmarksListState = rememberLazyListState() + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = bookmarksListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(bookmarksListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(bookmarks, key = { "nav_bookmark_${it.pageIndex}" }) { bookmark -> + Surface( + color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) } + ) { + Text( + bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }, + modifier = Modifier.padding(8.dp) + ) + } + } + } + SharedReaderVerticalScrollbar( + listState = bookmarksListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) } } } - } - if (document.toc.isNotEmpty()) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - itemsIndexed(document.toc, key = { index, entry -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry -> - Surface( - color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) } - ) { + 3 -> { + val pageRows = remember(document.pageCount) { (0 until document.pageCount).chunked(3) } + val pagesListState = rememberLazyListState() + val currentRowIndex = pageIndex / 3 + Column(modifier = Modifier.fillMaxSize()) { Row( - modifier = Modifier - .padding(start = (entry.nestLevel * 12).dp) - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly ) { - Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton( + onClick = { + navigationScope.launch { + pagesListState.animateScrollToItem(currentRowIndex.coerceIn(0, pageRows.lastIndex.coerceAtLeast(0))) + } + } + ) { + Text("Locate") + } } - } - } - } - if (bookmarks.isNotEmpty()) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - items(bookmarks, key = { "nav_bookmark_${it.pageIndex}" }) { bookmark -> - Surface( - color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) } - ) { - Text( - bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }, - modifier = Modifier.padding(8.dp) - ) - } - } - } - if (sortedAnnotations.isNotEmpty() || sortedEmbeddedAnnotations.isNotEmpty()) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Notes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { selectAnnotation(annotation) } - ) { - Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(annotation.desktopLabel(), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) - } - } - } - items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { selectEmbeddedAnnotation(annotation) } - ) { - Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(annotation.author.ifBlank { "PDF comment" }, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = pagesListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(pagesListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(pageRows, key = { row -> row.firstOrNull() ?: 0 }) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + row.forEach { page -> + DesktopPdfThumbnailTile( + document = document, + pageIndex = page, + selected = page == pageIndex, + onClick = { goToPage(page, recordJump = true) }, + modifier = Modifier.weight(1f) + ) + } + repeat(3 - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + SharedReaderVerticalScrollbar( + listState = pagesListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) } } } @@ -4000,55 +6332,74 @@ private fun PdfReaderScreen( @Composable fun PdfBottomChrome() { + val chromeBackground = MaterialTheme.colorScheme.surface + val chromeContent = MaterialTheme.colorScheme.onSurface + val sliderActive = MaterialTheme.colorScheme.primary + val sliderInactive = MaterialTheme.colorScheme.surfaceVariant Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp + shape = RoundedCornerShape(6.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { - Text("Previous") + Column(modifier = Modifier.fillMaxWidth()) { + DesktopPdfJumpHistoryControls( + visible = !isPdfSearchActive, + backPage = jumpHistory.backPage, + forwardPage = jumpHistory.forwardPage, + onBack = ::goBackInJumpHistory, + onForward = ::goForwardInJumpHistory, + onClear = { jumpHistory = jumpHistory.clear() } + ) + if (!isPdfSearchActive && jumpHistory.hasJumpTargets) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } - Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) - if (document.pageCount > 1) { - Slider( - value = pageIndex.toFloat(), - onValueChange = { value -> - if (pageScrubStartPage == null) { - pageScrubStartPage = pdfState.pageIndex - } - val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) - pageScrubPreview = targetPage - goToPage(targetPage) - }, - onValueChangeFinished = { - val startPage = pageScrubStartPage - val targetPage = currentPdfPageIndex - if (startPage != null) { - jumpHistory = jumpHistory.record( - currentPageIndex = startPage, - targetPageIndex = targetPage, - pageCount = document.pageCount - ) - } - pageScrubStartPage = null - pageScrubPreview = null - }, - valueRange = 0f..(document.pageCount - 1).toFloat(), - steps = (document.pageCount - 2).coerceAtLeast(0), - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Previous page", + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + Text( + "Page ${pageIndex + 1} of ${document.pageCount}", + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) ) - } else { - Spacer(Modifier.weight(1f)) - } - Text("${progressPercent.toInt()}%", color = MaterialTheme.colorScheme.onSurfaceVariant) - TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { - Text("Next") + if (document.pageCount > 1) { + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = ::updatePdfPageScrub, + onValueChangeFinished = ::finishPdfPageScrub, + valueRange = 0f..(document.pageCount - 1).toFloat(), + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(Modifier.weight(1f)) + } + Text( + "${progressPercent.toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) + ) + IconButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next page", + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } } } } @@ -4059,19 +6410,45 @@ private fun PdfReaderScreen( title = document.title, subtitle = "${document.formatLabel} - Page ${pageIndex + 1} of ${document.pageCount}", progressLabel = "${progressPercent.toInt()}%", - modifier = Modifier - .onPreviewKeyEvent(::handlePdfReaderKeyEvent) - .focusable(), - topActions = { - TextButton(onClick = onOpenBook) { - Text("Open Book") - } - TextButton(onClick = onOpenPdf) { - Text("Open PDF") + onReturnToLibrary = onReturnToLibrary?.let { returnToLibrary -> + { + persistPdfViewport() + returnToLibrary() } }, - leftSidebar = { PdfNavigationSidebar() }, + isFullscreen = isFullscreen, + onFullscreenChange = ::setPdfFullscreen, + isBookmarked = bookmarks.any { it.pageIndex == pageIndex }, + onToggleBookmark = { toggleBookmark(pageIndex) }, + onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) }, + topSearchBar = if (isPdfSearchActive) { + { + DesktopPdfSearchTopBar( + query = searchQuery, + showResultsPanel = showPdfSearchResultsPanel, + onQueryChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, + onClose = { dispatchPdf(SharedPdfReaderAction.SearchClosed) }, + onToggleResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) } + ) + } + } else { + null + }, + modifier = Modifier + .focusRequester(pdfReaderFocusRequester) + .onPreviewKeyEvent(::handlePdfReaderKeyEvent) + .focusable(), + leftSidebar = { _ -> PdfNavigationSidebar() }, rightInspector = { + var selectedPdfInspectorTab by remember(document.path) { mutableStateOf(DesktopPdfInspectorTab.VIEW) } + val viewInspectorListState = rememberLazyListState() + val markupInspectorListState = rememberLazyListState() + val assistInspectorListState = rememberLazyListState() + val pdfInspectorListState = when (selectedPdfInspectorTab) { + DesktopPdfInspectorTab.VIEW -> viewInspectorListState + DesktopPdfInspectorTab.MARKUP -> markupInspectorListState + DesktopPdfInspectorTab.ASSIST -> assistInspectorListState + } Surface( modifier = Modifier .width(340.dp) @@ -4079,432 +6456,278 @@ private fun PdfReaderScreen( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp) ) { - LazyColumn( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Text("Tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = displayMode == PdfDisplayMode.PAGINATION, - onClick = { - commitActiveTextDraft() - dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION)) - }, - label = { Text("Page") } - ) - FilterChip( - selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, - onClick = { - commitActiveTextDraft() - dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.VERTICAL_SCROLL)) - }, - label = { Text("Scroll") } - ) - } - } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { goToPage(0) }, enabled = canGoPrevious) { - Text("First") - } - TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { - Text("Prev") - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { - Text("Next") - } - TextButton(onClick = { goToPage(document.pageCount - 1) }, enabled = canGoNext) { - Text("Last") - } - } - } - } - item { - DesktopPdfJumpHistoryControls( - backPage = jumpHistory.backPage, - forwardPage = jumpHistory.forwardPage, - onBack = ::goBackInJumpHistory, - onForward = ::goForwardInJumpHistory, - onClear = { jumpHistory = jumpHistory.clear() } - ) - } - if (document.pageCount > 1) { - item { - Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) - Slider( - value = pageIndex.toFloat(), - onValueChange = { value -> - if (pageScrubStartPage == null) { - pageScrubStartPage = pdfState.pageIndex - } - val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) - pageScrubPreview = targetPage - goToPage(targetPage) - }, - onValueChangeFinished = { - val startPage = pageScrubStartPage - val targetPage = currentPdfPageIndex - if (startPage != null) { - jumpHistory = jumpHistory.record( - currentPageIndex = startPage, - targetPageIndex = targetPage, - pageCount = document.pageCount - ) - } - pageScrubStartPage = null - pageScrubPreview = null - }, - valueRange = 0f..(document.pageCount - 1).toFloat(), - steps = (document.pageCount - 2).coerceAtLeast(0) - ) - } - } - item { - val isBookmarked = bookmarks.any { it.pageIndex == pageIndex } - TextButton(onClick = { toggleBookmark(pageIndex) }) { - Text(if (isBookmarked) "Remove bookmark" else "Bookmark page") - } - } - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - SharedReaderThemeControls( - settings = pdfReaderSettings, - builtInThemes = BuiltInPdfReaderThemes, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - onSettingsChange = ::updatePdfReaderSettings - ) - } - if (bookmarks.isNotEmpty()) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - items(bookmarks, key = { "bookmark_${it.pageIndex}" }) { bookmark -> - Surface( - color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) } - ) { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = { toggleBookmark(bookmark.pageIndex) }) { - Text("Remove") - } - } - } - } - } - if (document.toc.isNotEmpty()) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - itemsIndexed(document.toc, key = { index, entry -> "toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry -> - Surface( - color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) } - ) { - Row( - modifier = Modifier - .padding(start = (entry.nestLevel * 12).dp) - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - entry.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - item { - Text("Zoom", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) }) { - Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out") - } - Text("${(scale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center) - IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) }) { - Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in") - } - } - Slider( - value = scale, - onValueChange = { dispatchPdf(SharedPdfReaderAction.ZoomChanged(it)) }, - valueRange = zoomSpec.min..zoomSpec.max - ) - } - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Annotations", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - FilterChip( - selected = isTextSelectionMode, - onClick = { - val enabled = !isTextSelectionMode - if (enabled) { - deactivateRichTextMode() - } - if (enabled) { - commitActiveTextDraft() - } - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) - if (!enabled) { - clearPdfInteractionState() - } - }, - label = { Text("Select text") } - ) - FilterChip( - selected = isRichTextMode, - onClick = { - if (isRichTextMode) { - deactivateRichTextMode() - } else { - activateRichTextMode() - } - }, - label = { Text("Document text") } - ) - SharedPdfAnnotationToolDock( - selectedTool = selectedTool, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - tools = DesktopPdfAnnotationTools, - onToolSelected = ::selectPdfAnnotationTool, - onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, - onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, - onUndo = { - dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex)) - }, - onClearPage = { - dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) - }, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - onHighlighterSnapChange = { isHighlighterSnapEnabled = it } - ) - } - selectedAnnotation?.let { annotation -> - item { - DesktopPdfAnnotationEditor( - annotation = annotation, - onUpdate = ::updateAnnotation, - onDelete = { deleteAnnotation(annotation.id) }, - onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } - ) - } - } - if (sortedAnnotations.isNotEmpty()) { - item { - Text("Annotation list", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - } - items(sortedAnnotations, key = { "annotation_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { selectAnnotation(annotation) } - ) { - Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - annotation.desktopLabel(), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = { deleteAnnotation(annotation.id) }) { - Text("Delete") - } - } - Text( - "Page ${annotation.pageIndex + 1}${annotation.text.takeIf { it.isNotBlank() }?.let { " - ${it.logPreview(48)}" }.orEmpty()}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - selectedEmbeddedAnnotation?.let { annotation -> - item { - DesktopPdfEmbeddedAnnotationPanel( - annotation = annotation, - onCopy = { clipboardManager.setText(AnnotatedString(annotation.threadText())) }, - onClose = { selectedEmbeddedAnnotationId = null } - ) - } - } - if (sortedEmbeddedAnnotations.isNotEmpty()) { - item { - Text("PDF comments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - } - items(sortedEmbeddedAnnotations, key = { "embedded_${it.id}" }) { annotation -> - Surface( - color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { selectEmbeddedAnnotation(annotation) } - ) { - Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - annotation.author.ifBlank { "PDF comment" }, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - Text("p. ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Text( - annotation.contents.ifBlank { annotation.replies.firstOrNull()?.contents.orEmpty() }.logPreview(80), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (annotation.replies.isNotEmpty()) { - Text( - "${annotation.replies.size} replies", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelSmall - ) - } - } - } - } - } - if (isRichTextMode || selectedTool == PdfInkTool.TEXT) { - item { - SharedPdfTextAnnotationDock( - style = if (isRichTextMode) { - richTextController.currentSharedPdfTextStyleConfig() - } else { - effectiveTextStyleConfig - }, - onStyleChange = { style -> - if (isRichTextMode) { - richTextController.updateCurrentSharedPdfTextStyle(style) - } else { - updateTextStyleConfig(style) - } - } - ) - } - } - item { - DesktopPdfExtrasPanel( - pageText = currentPdfPageText(), - recapText = pdfTextBeforeCurrentPage(), - extrasState = pdfExtrasState, - aiByokSettings = aiByokSettings, - onExternalLookup = ::openPdfExternalLookup, - onAiAction = ::runPdfAiAction, - onCloudTtsStart = ::startPdfCloudTts, - onCloudTtsPauseResume = ::pauseResumePdfCloudTts, - onCloudTtsStop = ::stopPdfCloudTts, - onCloudTtsClearCache = ::clearPdfCloudTtsCache, - onAutoScrollChange = ::updatePdfAutoScroll, - ttsReplacementPreferences = state.readerTtsReplacementPreferences, - ttsReplacementBookId = document.path, - onTtsReplacementPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) - } - ) - } - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = searchQuery, - onValueChange = { - dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) - }, - label = { Text("Find in PDF") }, - singleLine = true, + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + ScrollableTabRow( + selectedTabIndex = selectedPdfInspectorTab.ordinal, + edgePadding = 0.dp, modifier = Modifier.fillMaxWidth() - ) - } - if (searchQuery.isNotBlank()) { - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - when { - isSearchIndexing -> { - val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}" - if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress" - } - searchResults.isEmpty() -> "No matches" - activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}" - else -> "${searchResults.size} matches" - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) { - Text("Prev") - } - TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) { - Text("Next") - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - "Highlights", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton( - onClick = { - dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) - }, - enabled = searchResults.isNotEmpty() - ) { - Text( - when (searchHighlightMode) { - SearchHighlightMode.ALL -> "All" - SearchHighlightMode.FOCUSED -> "Focused" - } - ) - } - } - } - } - items(searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> - Surface( - color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { - goToSearchResult(searchResults.indexOf(result)) - } ) { - Column(modifier = Modifier.padding(8.dp)) { - Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold) - Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) + DesktopPdfInspectorTab.values().forEach { tab -> + Tab( + selected = selectedPdfInspectorTab == tab, + onClick = { selectedPdfInspectorTab = tab }, + text = { + Text( + tab.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) } } } + HorizontalDivider() + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = pdfInspectorListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(pdfInspectorListState, multiplier = 2.8f) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + when (selectedPdfInspectorTab) { + DesktopPdfInspectorTab.VIEW -> { + item { + DesktopPdfInspectorSection("Reading") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION, + onClick = { + commitActiveTextDraft() + dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION)) + }, + label = { Text("Page") } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, + onClick = { + commitActiveTextDraft() + dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.VERTICAL_SCROLL)) + }, + label = { Text("Scroll") } + ) + } + } + } + item { + DesktopPdfInspectorSection("Position") { + Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) + if (document.pageCount > 1) { + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = ::updatePdfPageScrub, + onValueChangeFinished = ::finishPdfPageScrub, + valueRange = 0f..(document.pageCount - 1).toFloat() + ) + } + } + } + item { + DesktopPdfInspectorSection("Appearance") { + SharedReaderThemeControls( + settings = pdfReaderSettings, + builtInThemes = BuiltInPdfReaderThemes, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + onSettingsChange = ::updatePdfReaderSettings + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Text("Visual options", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + DesktopPdfVisualOptionSwitch( + title = "Remove gap between pages", + description = "Applies to vertical reading mode.", + checked = !pdfReaderSettings.pdfVerticalPageGapVisible, + onCheckedChange = { removeGap -> + updatePdfReaderSettings( + pdfReaderSettings.copy(pdfVerticalPageGapVisible = !removeGap) + ) + } + ) + DesktopPdfVisualOptionSwitch( + title = "Hide page number overlay", + description = "Removes the small page count label from each page.", + checked = !pdfReaderSettings.pdfPageNumberOverlayVisible, + onCheckedChange = { hideOverlay -> + updatePdfReaderSettings( + pdfReaderSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay) + ) + } + ) + } + } + item { + DesktopPdfInspectorSection("Zoom") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) + }) { + Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out") + } + Text("${(zoomControlScale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center) + IconButton(onClick = { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) + }) { + Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in") + } + } + Slider( + value = zoomControlScale, + onValueChange = { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomChanged(it)) + }, + valueRange = zoomSpec.min..zoomSpec.max + ) + } + } + } + DesktopPdfInspectorTab.MARKUP -> { + item { + DesktopPdfInspectorSection("Interaction") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode, + onClick = ::selectPdfPanMode, + label = { Text("Pan") } + ) + FilterChip( + selected = isTextSelectionMode, + onClick = { + val enabled = !isTextSelectionMode + if (enabled) { + deactivateRichTextMode() + commitActiveTextDraft() + } + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) + if (!enabled) { + clearPdfInteractionState() + } + }, + label = { Text("Select text") } + ) + FilterChip( + selected = isRichTextMode, + onClick = { + if (isRichTextMode) { + deactivateRichTextMode() + } else { + activateRichTextMode() + } + }, + label = { Text("Document text") } + ) + } + } + } + item { + DesktopPdfInspectorSection("Annotation tools") { + SharedPdfAnnotationToolDock( + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + tools = DesktopPdfAnnotationTools, + highlighterPalette = pdfHighlighterColors, + onToolSelected = ::selectPdfAnnotationTool, + onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, + onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, + onUndo = { + dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex)) + }, + onClearPage = { + dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) + }, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = { isHighlighterSnapEnabled = it } + ) + } + } + item { + DesktopPdfInspectorSection("Highlighter palette") { + SharedPdfHighlighterPaletteEditor( + palette = pdfHighlighterPalette, + onPaletteChange = ::updatePdfHighlighterPalette + ) + } + } + if (isRichTextMode || selectedTool == PdfInkTool.TEXT) { + item { + DesktopPdfInspectorSection("Text style") { + SharedPdfTextAnnotationDock( + style = if (isRichTextMode) { + richTextController.currentSharedPdfTextStyleConfig() + } else { + effectiveTextStyleConfig + }, + onStyleChange = { style -> + if (isRichTextMode) { + richTextController.updateCurrentSharedPdfTextStyle(style) + } else { + updateTextStyleConfig(style) + } + } + ) + } + } + } + } + DesktopPdfInspectorTab.ASSIST -> { + item { + DesktopPdfExtrasPanel( + pageText = currentPdfPageText(), + recapText = pdfTextBeforeCurrentPage(), + extrasState = pdfExtrasState, + aiByokSettings = aiByokSettings, + externalLookupAvailable = featurePolicy.externalLookup, + cloudTtsFeatureAvailable = featurePolicy.aiAndCloud, + onExternalLookup = ::openPdfExternalLookup, + onAiAction = ::runPdfAiAction, + onCloudTtsStart = ::startPdfCloudTts, + onCloudTtsPauseResume = ::pauseResumePdfCloudTts, + onCloudTtsStop = ::stopPdfCloudTts, + onCloudTtsClearCache = ::clearPdfCloudTtsCache, + onAutoScrollChange = ::updatePdfAutoScroll, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = document.path, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange + ) + } + } + } + } + SharedReaderVerticalScrollbar( + listState = pdfInspectorListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } } } }, - bottomBar = { PdfBottomChrome() } + bottomBar = { PdfBottomChrome() }, + fullscreenBottomBar = { + DesktopPdfFullscreenBottomChrome( + pageIndex = pageIndex, + pageCount = document.pageCount, + showJumpHistory = !isPdfSearchActive, + jumpBackPage = jumpHistory.backPage, + jumpForwardPage = jumpHistory.forwardPage, + onPrevious = { goToPage(pageIndex - 1) }, + onNext = { goToPage(pageIndex + 1) }, + onPageScrub = ::updatePdfPageScrub, + onPageScrubFinished = ::finishPdfPageScrub, + onJumpBack = ::goBackInJumpHistory, + onJumpForward = ::goForwardInJumpHistory, + onClearJumpHistory = { jumpHistory = jumpHistory.clear() } + ) + } ) { SharedPdfRichTextHiddenInput( controller = richTextController, @@ -4514,19 +6737,55 @@ private fun PdfReaderScreen( .padding(start = 16.dp, bottom = 24.dp) .zIndex(10f) ) - if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { - Box( - modifier = Modifier - .fillMaxSize() - .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp)) - ) { - LazyColumn( + DesktopPdfSearchOverlay( + isSearchActive = isPdfSearchActive, + showResultsPanel = showPdfSearchResultsPanel, + query = searchQuery, + results = searchResults, + activeSearchIndex = activeSearchIndex, + highlightMode = searchHighlightMode, + isIndexing = isSearchIndexing, + indexedPageCount = indexedSearchPageCount, + pageCount = document.pageCount, + onResultClick = { index -> + goToSearchResult(index) + dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) + }, + onShowResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) }, + onPrevious = { goToSearchResult(activeSearchIndex - 1) }, + onNext = { goToSearchResult(activeSearchIndex + 1) }, + onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) } + ) + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + val verticalPageGap = pdfVerticalPageGapDp( + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible, + defaultGap = DesktopDefaultPdfVerticalPageGap + ) + val verticalViewportBackground = desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, + gapBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(verticalViewportBackground, RoundedCornerShape(8.dp)) + .onGloballyPositioned { coordinates -> + pdfZoomViewportRootOffset = coordinates.positionInRoot() + } + .desktopPdfZoomGestures( + currentZoom = scale, + zoomSpec = zoomSpec, + onZoomChanged = ::previewAnchoredPdfZoom + ) + ) { + LazyColumn( state = verticalListState, modifier = Modifier .fillMaxSize() .horizontalScroll(pageHorizontalScrollState) .padding(horizontal = 24.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(verticalPageGap), horizontalAlignment = Alignment.CenterHorizontally ) { items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> @@ -4546,6 +6805,7 @@ private fun PdfReaderScreen( selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, selectedTool = selectedTool, selectedColor = selectedColor, + highlighterPalette = pdfHighlighterColors, strokeWidth = strokeWidth, isHighlighterSnapEnabled = isHighlighterSnapEnabled, activeTextDraft = activeTextDraft, @@ -4553,8 +6813,14 @@ private fun PdfReaderScreen( isRichTextMode = isRichTextMode, readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + externalLookupAvailable = featurePolicy.externalLookup, themeStyle = pdfThemeStyle, shouldRender = verticalPageIndex in verticalRenderWindow, + zoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + }, + zoomViewportRootOffset = pdfZoomViewportRootOffset, + showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, onSelectPage = { goToPage( target = it, @@ -4564,13 +6830,12 @@ private fun PdfReaderScreen( }, onCopySelection = ::copySelection, onHighlightSelection = ::highlightSelection, - onSearchSelection = ::searchSelection, - onWebSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }, - onDictionarySelection = { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) }, + onExternalSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, onSpeakSelection = { togglePdfCloudTts(it.text) }, - onTranslateSelection = ::translateSelection, onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, + onAnnotationSelected = ::selectAnnotation, onLinkActivated = ::activatePdfLink, onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, onAnnotationUpdated = ::updateAnnotation, @@ -4578,10 +6843,26 @@ private fun PdfReaderScreen( onTextAnnotationSelected = ::selectTextAnnotation, onTextDraftStarted = ::startActiveTextDraft, onTextDraftChanged = ::updateActiveTextDraft, - onTextDraftBoundsChanged = ::updateActiveTextDraftBounds + onTextDraftBoundsChanged = ::updateActiveTextDraftBounds, + onPan = { delta -> + pdfScope.launch { + pageHorizontalScrollState.scrollBy(-delta.x) + verticalListState.scrollBy(-delta.y) + } + }, + onPagePositioned = { page, offset -> + verticalPageRootOffsets[page] = offset + } ) } } + SharedPdfVerticalScrollbar( + listState = verticalListState, + pageCount = document.pageCount, + currentPage = pageIndex, + isDarkMode = verticalViewportBackground.luminance() < 0.5f, + modifier = Modifier.align(Alignment.CenterEnd) + ) DesktopPdfPageScrubOverlay( pageIndex = pageScrubPreview, pageCount = document.pageCount @@ -4592,19 +6873,31 @@ private fun PdfReaderScreen( modifier = Modifier .fillMaxSize() .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp)) + .onGloballyPositioned { coordinates -> + pdfZoomViewportRootOffset = coordinates.positionInRoot() + } + .desktopPdfZoomGestures( + currentZoom = scale, + zoomSpec = zoomSpec, + onZoomChanged = ::previewAnchoredPdfZoom + ) .horizontalScroll(pageHorizontalScrollState) .verticalScroll(pageVerticalScrollState) .padding(24.dp), contentAlignment = Alignment.TopCenter ) { - when { - isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) - renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) - renderedPage != null -> { - val pageRender = renderedPage!! - val pageWidthDp = with(density) { pageRender.width.toDp() } - val pageHeightDp = with(density) { pageRender.height.toDp() } - val pageRenderScale = pageRender.width / document.pageSizes[pageIndex].width + val currentPageRender = renderedPage.takeIf { renderedPageIndex == pageIndex } + when { + currentPageRender != null -> { + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + Text("Failed to render page.", color = MaterialTheme.colorScheme.error) + return@Box + } + val pageDisplayScale = zoomSpec.clamp(scale) + val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() } + val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() } + val pageRenderScale = currentPageRender.width / pageSize.width val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { annotations .filter { it.pageIndex == pageIndex } @@ -4681,19 +6974,35 @@ private fun PdfReaderScreen( .mergePdfBoundsByLine() } } + val pageZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.PAGINATION && + it.pageIndex == pageIndex + } Box( modifier = Modifier .size(pageWidthDp, pageHeightDp) + .onGloballyPositioned { coordinates -> + paginatedPageRootOffset = coordinates.positionInRoot() + } .onSizeChanged { size -> if (pageCanvasSize != size) { logPdfSelection( "layout page=${pageIndex + 1} " + - "canvas=${size.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + - "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()}" + "canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + + "requestedScale=${scale.formatLogFloat()} displayScale=${pageDisplayScale.formatLogFloat()} " + + "renderScale=${pageRenderScale.formatLogFloat()}" ) } pageCanvasSize = size } + .desktopPdfZoomPreviewLayer( + preview = pageZoomPreview, + currentZoom = scale, + viewportRootOffset = pdfZoomViewportRootOffset, + pageRootOffset = paginatedPageRootOffset, + pageCanvasSize = pageCanvasSize + ) + .background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { if (isRichTextMode) return@pointerInput awaitPointerEventScope { @@ -4701,6 +7010,21 @@ private fun PdfReaderScreen( val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { + currentPdfAnnotations.asReversed().firstOrNull { + it.isDesktopTextSelectionHighlight && + it.pageIndex == pageIndex && + it.sharedPdfHitTest(point, pageCanvasSize) + } + } else { + null + } + if (highlightHit != null) { + selectAnnotation(highlightHit) + clearPdfInteractionState() + event.changes.forEach { it.consume() } + continue + } if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { @@ -4746,6 +7070,64 @@ private fun PdfReaderScreen( } } } + .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) { + if (isRichTextMode || !isTextSelectionMode) return@pointerInput + detectTapGestures( + onLongPress = { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + if (selection != null) { + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } + ) + } + .pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) { + if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val pointerId = down.id + var dragStarted = false + var dragDistance = 0f + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: return@awaitEachGesture + if (change.changedToUp()) { + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val delta = change.positionChange() + if (!dragStarted) { + dragDistance += delta.getDistance() + if (dragDistance <= viewConfiguration.touchSlop) { + continue + } + dragStarted = true + change.consume() + continue + } + pdfScope.launch { + pageHorizontalScrollState.scrollBy(-delta.x) + pageVerticalScrollState.scrollBy(-delta.y) + } + change.consume() + } + } + } .pointerInput( pageIndex, isTextSelectionMode, @@ -4756,50 +7138,84 @@ private fun PdfReaderScreen( textStyleConfig, activeTextDraft?.id, isRichTextMode, - pageCanvasSize, - pageRender.width, - pageRender.height + pageCanvasSize, currentPageRender.width, + currentPageRender.height ) { if (isRichTextMode) return@pointerInput if (isTextSelectionMode) { + var latestSelectionDragPoint: Offset? = null + var lastSelectionPreviewAt = 0L detectDragGestures( onDragStart = { start -> + latestSelectionDragPoint = start + lastSelectionPreviewAt = 0L selectionMenuOffset = null + val existingSelection = textSelection + val handle = existingSelection?.handleAt(start, pageCanvasSize) + activeSelectionHandle = handle val hit = document.charHitAt(pageIndex, start, pageCanvasSize) - selectionStartHit = hit - selectionStartIndex = hit?.index - selectionEndHit = null - selectionEndIndex = null + if (handle != null && existingSelection != null) { + selectionStartHit = null + selectionStartIndex = when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.endIndex + DesktopPdfSelectionHandle.END -> existingSelection.startIndex + } + selectionEndHit = hit + selectionEndIndex = hit?.index ?: when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.startIndex + DesktopPdfSelectionHandle.END -> existingSelection.endIndex + } + } else { + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + textSelection = null + } logPdfSelection( "drag_start page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + "handle=${handle?.name ?: "none"} " + hit.formatLogHit("start") ) - textSelection = null }, onDrag = { change, _ -> - val startIndex = selectionStartIndex - val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) - selectionEndHit = hit - val endIndex = hit?.index - val previousEndIndex = selectionEndIndex - selectionEndIndex = endIndex - if (endIndex != previousEndIndex || textSelection == null) { - textSelection = if (startIndex != null && endIndex != null) { - document.selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = pageCanvasSize, - useNativeBounds = false - ) - } else { - null + latestSelectionDragPoint = change.position + val now = System.currentTimeMillis() + if (lastSelectionPreviewAt == 0L || + now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis + ) { + lastSelectionPreviewAt = now + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionPreviewBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize + ) + } else { + null + } } } + change.consume() }, onDragEnd = { + val finalHit = latestSelectionDragPoint + ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } + ?: selectionEndHit + if (finalHit != null) { + selectionEndHit = finalHit + selectionEndIndex = finalHit.index + } val startIndex = selectionStartIndex val endIndex = selectionEndIndex val selection = if (startIndex != null && endIndex != null) { @@ -4809,13 +7225,18 @@ private fun PdfReaderScreen( endIndex = endIndex, canvasSize = pageCanvasSize, useNativeBounds = true - )?.also { textSelection = it } + ) } else { - textSelection + textSelection?.takeIf { it.text.isNotBlank() } } + textSelection = selection + selectionMenuOffset = selection?.menuAnchor( + pageCanvasSize, + finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point + ) logPdfSelection( "drag_end page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + selectionStartHit.formatLogHit("start") + " " + selectionEndHit.formatLogHit("end") + " " + @@ -4828,11 +7249,14 @@ private fun PdfReaderScreen( selectionEndIndex = null selectionStartHit = null selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L }, onDragCancel = { logPdfSelection( "drag_cancel page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + selectionStartHit.formatLogHit("start") + " " + selectionEndHit.formatLogHit("end") @@ -4841,6 +7265,9 @@ private fun PdfReaderScreen( selectionEndIndex = null selectionStartHit = null selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L } ) } else if (selectedTool == PdfInkTool.TEXT) { @@ -4863,30 +7290,77 @@ private fun PdfReaderScreen( } } ) - } else { + } else if (selectedTool != PdfInkTool.NONE) { var eraserPreviousPoint: Offset? = null - detectDragGestures( - onDragStart = { start -> - if (selectedTool == PdfInkTool.ERASER) { - val annotationSnapshot = currentPdfAnnotations - val updatedAnnotations = annotationSnapshot.filterNot { - it.pageIndex == pageIndex && it.sharedPdfHitTest( - point = start, - size = pageCanvasSize, - eraserStrokeWidth = strokeWidth + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val start = down.position + if (selectedTool == PdfInkTool.ERASER) { + eraserPosition = start + val annotationSnapshot = currentPdfAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf(start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis())) + } + + val pointerId = down.id + var dragStarted = false + while (true) { + val event = awaitPointerEvent() + if (event.changes.size > 1) { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + val change = event.changes.firstOrNull { it.id == pointerId } + ?: run { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (change.changedToUp()) { + change.consume() + if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { + dispatchPdf( + SharedPdfReaderAction.AnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) + ) ) } - if (updatedAnnotations.size != annotationSnapshot.size) { - dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) - } - eraserPreviousPoint = start - } else { - activeStroke = listOf(start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis())) + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture } - }, - onDrag = { change, _ -> + if (!change.positionChanged()) continue + val distance = (change.position - start).getDistance() + if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue + dragStarted = true if (selectedTool == PdfInkTool.ERASER) { val point = change.position + eraserPosition = point val previousPoint = eraserPreviousPoint val annotationSnapshot = currentPdfAnnotations val updatedAnnotations = annotationSnapshot.filterNot { @@ -4910,37 +7384,14 @@ private fun PdfReaderScreen( timestamp = System.currentTimeMillis() ) } - }, - onDragEnd = { - eraserPreviousPoint = null - if (activeStroke.size > 1) { - dispatchPdf( - SharedPdfReaderAction.AnnotationAdded( - SharedPdfAnnotation( - id = "ink_${System.currentTimeMillis()}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.INK, - tool = selectedTool, - points = activeStroke, - colorArgb = selectedColor, - strokeWidth = strokeWidth, - createdAt = System.currentTimeMillis() - ) - ) - ) - } - activeStroke = emptyList() - }, - onDragCancel = { - eraserPreviousPoint = null - activeStroke = emptyList() + change.consume() } - ) + } } } ) { DesktopPdfThemedPageImage( - bitmap = pageRender.image, + bitmap = currentPageRender.image, contentDescription = "PDF page ${pageIndex + 1}", themeStyle = pdfThemeStyle, modifier = Modifier.fillMaxSize() @@ -4977,7 +7428,15 @@ private fun PdfReaderScreen( activeTool = selectedTool, activeStrokeColorArgb = selectedColor, activeStrokeWidth = strokeWidth, - selectedAnnotationId = selectedAnnotationId + selectedAnnotationId = selectedAnnotationId, + eraserPosition = eraserPosition, + showEraserIndicator = selectedTool == PdfInkTool.ERASER, + eraserStrokeWidth = strokeWidth + ) + PdfTextSelectionHandles( + selection = textSelection, + canvasSize = pageCanvasSize, + activeHandle = activeSelectionHandle ) SharedPdfInlineTextEditorOverlay( draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, @@ -5008,10 +7467,12 @@ private fun PdfReaderScreen( canvasSize = pageCanvasSize, selectedAnnotationId = selectedEmbeddedAnnotationId ) - SharedPdfPageNumberOverlay( - pageIndex = pageIndex, - pageCount = document.pageCount - ) + if (pdfReaderSettings.pdfPageNumberOverlayVisible) { + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + } if (textSelection != null && selectionMenuOffset != null) { Box( modifier = Modifier @@ -5030,48 +7491,562 @@ private fun PdfReaderScreen( selection = textSelection, menuOffset = selectionMenuOffset, canvasSize = pageCanvasSize, + highlighterPalette = pdfHighlighterColors, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onCopy = { textSelection?.let(::copySelection) clearSelection() }, - onHighlight = ::highlightCurrentSelection, + onHighlight = { colorArgb -> + textSelection?.let { selection -> + highlightSelection(pageIndex, selection, pageCanvasSize, colorArgb) + } + clearSelection() + }, onSearch = { - textSelection?.let(::searchSelection) - selectionMenuOffset = null - }, - onWebSearch = { textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) } - selectionMenuOffset = null - }, - onDictionary = { - textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) } - selectionMenuOffset = null + clearSelection() }, onDefine = { textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) } - selectionMenuOffset = null + clearSelection() }, onSpeak = { textSelection?.let { togglePdfCloudTts(it.text) } - selectionMenuOffset = null - }, - onTranslate = { - textSelection?.let(::translateSelection) - selectionMenuOffset = null + clearSelection() }, showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable, + showSearch = featurePolicy.externalLookup, onClear = ::clearSelection ) } } + isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) + renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) } - DesktopPdfPageScrubOverlay( - pageIndex = pageScrubPreview, - pageCount = document.pageCount - ) + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount + ) } } + AnimatedVisibility( + visible = showPdfZoomIndicator, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp), + enter = fadeIn(), + exit = fadeOut() + ) { + DesktopPdfZoomPercentageIndicator( + percentage = (zoomControlScale * 100).roundToInt(), + onResetZoomClick = { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomChanged(1f)) + } + ) + } + when { + selectedTextHighlight != null -> { + DesktopReaderBottomSheet( + title = selectedTextHighlight.desktopSheetTitle(), + onDismiss = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } + ) { + DesktopPdfAnnotationEditor( + annotation = selectedTextHighlight, + onUpdate = ::updateAnnotation, + onDelete = { deleteAnnotation(selectedTextHighlight.id) }, + onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) }, + onCopy = { + clipboardManager.setText(AnnotatedString(selectedTextHighlight.text)) + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + }, + showSearch = featurePolicy.externalLookup, + highlighterPalette = pdfHighlighterColors, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onSearch = { + openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text) + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + } + ) + } + } + selectedEmbeddedAnnotation != null -> { + DesktopReaderBottomSheet( + title = "PDF comment", + onDismiss = { selectedEmbeddedAnnotationId = null } + ) { + DesktopPdfEmbeddedAnnotationPanel( + annotation = selectedEmbeddedAnnotation, + onCopy = { clipboardManager.setText(AnnotatedString(selectedEmbeddedAnnotation.threadText())) }, + onClose = { selectedEmbeddedAnnotationId = null } + ) + } + } + pdfExtrasState.aiResult.hasContent -> { + DesktopReaderAiResultSheet( + result = pdfExtrasState.aiResult, + onDismiss = { pdfExtrasState = pdfExtrasState.copy(aiResult = ReaderAiResultState()) } + ) + } + } + } +} + +@Composable +private fun DesktopPdfFullscreenBottomChrome( + pageIndex: Int, + pageCount: Int, + showJumpHistory: Boolean, + jumpBackPage: Int?, + jumpForwardPage: Int?, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageScrub: (Float) -> Unit, + onPageScrubFinished: () -> Unit, + onJumpBack: () -> Unit, + onJumpForward: () -> Unit, + onClearJumpHistory: () -> Unit +) { + val chromeBackground = MaterialTheme.colorScheme.surface + val chromeContent = MaterialTheme.colorScheme.onSurface + val sliderActive = MaterialTheme.colorScheme.primary + val sliderInactive = MaterialTheme.colorScheme.surfaceVariant + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp), + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + if (showJumpHistory && hasJumpTargets) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val canGoPrevious = pageIndex > 0 + val canGoNext = pageIndex < pageCount - 1 + IconButton(onClick = onPrevious, enabled = canGoPrevious) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Previous page", + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), + enabled = pageCount > 1, + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onNext, enabled = canGoNext) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next page", + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } + } + } + } +} + +@Composable +private fun DesktopPdfZoomPercentageIndicator( + percentage: Int, + onResetZoomClick: () -> Unit +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = "$percentage%", + color = Color.White, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .width(1.dp) + .height(16.dp) + .background(Color.White.copy(alpha = 0.5f)) + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.ZoomOut, + contentDescription = "Reset zoom", + tint = Color.White, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onResetZoomClick) + ) + } + } +} + +@Composable +private fun DesktopPdfSearchTopBar( + query: String, + showResultsPanel: Boolean, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, + onToggleResults: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(80) + runCatching { focusRequester.requestFocus() } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + SharedStableOutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search in PDF") }, + singleLine = true, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + trailingIcon = if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + } else { + null + }, + selectionKey = "desktop-pdf-search" + ) + IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) { + Icon( + if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showResultsPanel) "Hide search results" else "Show search results" + ) + } + } + } +} + +@Composable +private fun BoxScope.DesktopPdfSearchOverlay( + isSearchActive: Boolean, + showResultsPanel: Boolean, + query: String, + results: List, + activeSearchIndex: Int, + highlightMode: SearchHighlightMode, + isIndexing: Boolean, + indexedPageCount: Int, + pageCount: Int, + onResultClick: (Int) -> Unit, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + AnimatedVisibility( + visible = isSearchActive && showResultsPanel, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.fillMaxSize().zIndex(30f) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column(Modifier.fillMaxSize()) { + if (isIndexing) { + val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat() + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { + Text( + "Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages", + style = MaterialTheme.typography.bodySmall + ) + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + ) + } + } + } + + when { + query.isBlank() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + results.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (isIndexing) "No matches in indexed pages yet" else "No matches", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + Text( + when { + isIndexing -> "${results.size} matches so far" + else -> "${results.size} matches" + }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + HorizontalDivider() + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed( + items = results, + key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } + ) { index, result -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, + color = if (index == activeSearchIndex) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Page ${result.pageIndex + 1}", + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + result.preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + HorizontalDivider() + } + } + } + } + } + } + } + + AnimatedVisibility( + visible = isSearchActive && !showResultsPanel && results.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 18.dp) + .zIndex(31f) + ) { + DesktopPdfSearchNavigationPill( + activeSearchIndex = activeSearchIndex, + resultCount = results.size, + highlightMode = highlightMode, + onShowResults = onShowResults, + onPrevious = onPrevious, + onNext = onNext, + onToggleHighlightMode = onToggleHighlightMode + ) + } +} + +@Composable +private fun DesktopPdfSearchNavigationPill( + activeSearchIndex: Int, + resultCount: Int, + highlightMode: SearchHighlightMode, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) { + Icon( + if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = "Toggle search highlights", + tint = if (highlightMode == SearchHighlightMode.ALL) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result") + } + Text( + text = if (activeSearchIndex in 0 until resultCount) { + "${activeSearchIndex + 1}/$resultCount" + } else { + "$resultCount matches" + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) + ) + IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result") + } + } + } +} + +@Composable +private fun DesktopReaderBottomSheet( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + SharedReaderPopupLayer(onDismiss = onDismiss) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .zIndex(40f) + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + val sheetHorizontalPadding = 24.dp + val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) + .width(sharedReaderPopupWidth(sheetAvailableWidth)) + .heightIn(max = 560.dp), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(42.dp) + .height(4.dp) + .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + content() + } + } + } + } + } +} + +@Composable +private fun DesktopReaderAiResultSheet( + result: ReaderAiResultState, + onDismiss: () -> Unit +) { + DesktopReaderBottomSheet( + title = result.title ?: "AI", + onDismiss = onDismiss + ) { + val errorMessage = result.errorMessage + when { + result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) + errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) + else -> SharedMarkdownText(result.text) + } } } @@ -5128,7 +8103,7 @@ private fun DesktopAiByokSettingsDialog( ) } } - OutlinedTextField( + SharedStableOutlinedTextField( value = pendingKey, onValueChange = { pendingKey = it }, label = { Text("API key") }, @@ -5315,6 +8290,8 @@ private fun DesktopPdfExtrasPanel( recapText: String, extrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, + externalLookupAvailable: Boolean, + cloudTtsFeatureAvailable: Boolean, onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, onAiAction: (ReaderAiFeature, String) -> Unit, onCloudTtsStart: (ReaderTtsReadScope) -> Unit, @@ -5332,14 +8309,16 @@ private fun DesktopPdfExtrasPanel( Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - ReaderExternalLookupAction.entries.forEach { action -> - FilterChip( - selected = false, - enabled = pageText.isNotBlank(), - onClick = { onExternalLookup(action, pageText) }, - label = { Text(action.title) } - ) + if (externalLookupAvailable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + ReaderExternalLookupAction.entries.forEach { action -> + FilterChip( + selected = false, + enabled = pageText.isNotBlank(), + onClick = { onExternalLookup(action, pageText) }, + label = { Text(action.title) } + ) + } } } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -5355,71 +8334,73 @@ private fun DesktopPdfExtrasPanel( valueRange = 12f..160f ) val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - when { - extrasState.cloudTts.isLoading -> "Preparing audio" - extrasState.cloudTts.isPaused -> "Paused" - extrasState.cloudTts.isPlaying -> "Reading" - settings.isCloudTtsAvailable -> "Cloud TTS ready" - else -> "Cloud TTS needs Gemini" - }, - fontWeight = FontWeight.SemiBold - ) - extrasState.cloudTts.errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - val statusMessage = extrasState.cloudTts.progress.currentPositionLabel - ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } - statusMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - TextButton( - enabled = settings.isCloudTtsAvailable || ttsBusy, - onClick = { - if (ttsBusy) { - onCloudTtsStop() - } else { - onCloudTtsStart(ReaderTtsReadScope.BOOK) + if (cloudTtsFeatureAvailable) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> "Preparing audio" + extrasState.cloudTts.isPaused -> "Paused" + extrasState.cloudTts.isPlaying -> "Reading" + settings.isCloudTtsAvailable -> "Cloud TTS ready" + else -> "Cloud TTS needs Gemini" + }, + fontWeight = FontWeight.SemiBold + ) + extrasState.cloudTts.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } + statusMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - ) { - Text(if (ttsBusy) "Stop" else "Read") - } - } - if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onCloudTtsPauseResume) { - Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") + TextButton( + enabled = settings.isCloudTtsAvailable || ttsBusy, + onClick = { + if (ttsBusy) { + onCloudTtsStop() + } else { + onCloudTtsStart(ReaderTtsReadScope.BOOK) + } + } + ) { + Text(if (ttsBusy) "Stop" else "Read") } } - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } - ) { - Text("Page") + if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton(onClick = onCloudTtsPauseResume) { + Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") + } + } } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } - ) { - Text("From here") + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } + ) { + Text("Page") + } + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } + ) { + Text("From here") + } } - } - val cacheSummary = extrasState.cloudTts.cacheSummary - if (cacheSummary.hasCachedAudio) { - Text( - "Cache: ${cacheSummary.currentVoiceLabel}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (cacheSummary.hasCurrentVoiceCachedAudio) { - TextButton(onClick = onCloudTtsClearCache) { - Text("Clear voice cache") + val cacheSummary = extrasState.cloudTts.cacheSummary + if (cacheSummary.hasCachedAudio) { + Text( + "Cache: ${cacheSummary.currentVoiceLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cacheSummary.hasCurrentVoiceCachedAudio) { + TextButton(onClick = onCloudTtsClearCache) { + Text("Clear voice cache") + } } } } @@ -5443,25 +8424,14 @@ private fun DesktopPdfExtrasPanel( Text("Recap") } } - if (extrasState.aiResult.hasContent) { - Surface(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - val aiErrorMessage = extrasState.aiResult.errorMessage - Text(extrasState.aiResult.title ?: "AI", fontWeight = FontWeight.SemiBold) - when { - extrasState.aiResult.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) - aiErrorMessage != null -> Text(aiErrorMessage, color = MaterialTheme.colorScheme.error) - else -> SharedMarkdownText(extrasState.aiResult.text) - } - } - } - } } } } @Composable private fun DesktopPdfJumpHistoryControls( + visible: Boolean, + modifier: Modifier = Modifier, backPage: Int?, forwardPage: Int?, onBack: () -> Unit, @@ -5469,70 +8439,251 @@ private fun DesktopPdfJumpHistoryControls( onClear: () -> Unit ) { val hasJumpTargets = backPage != null || forwardPage != null - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp) + AnimatedVisibility( + visible = visible && hasJumpTargets, + enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), + exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), + modifier = modifier.fillMaxWidth() ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Jump history", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = onClear, - enabled = hasJumpTargets, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.Close, contentDescription = "Clear jump history") - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + TextButton( + onClick = onBack, + enabled = backPage != null, + modifier = Modifier.weight(1f) ) { - TextButton( - onClick = onBack, - enabled = backPage != null, - modifier = Modifier.weight(1f) - ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Jump back", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + backPage?.let { "P. ${it + 1}" } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + TextButton( + onClick = onClear, + modifier = Modifier.weight(0.8f) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear jump history", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + TextButton( + onClick = onForward, + enabled = forwardPage != null, + modifier = Modifier.weight(1f) + ) { + Text( + forwardPage?.let { "P. ${it + 1}" } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(4.dp)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Jump forward", + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +private fun desktopPdfTocParentIndices(toc: List): Set { + return toc.indices.filter { index -> + val next = toc.getOrNull(index + 1) + next != null && next.nestLevel > toc[index].nestLevel + }.toSet() +} + +private fun desktopPdfTocAncestorIndices( + toc: List, + originalIndex: Int +): Set { + val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet() + val ancestors = mutableSetOf() + var currentDepth = targetDepth + for (index in originalIndex downTo 0) { + val entry = toc[index] + if (entry.nestLevel < currentDepth) { + ancestors += index + currentDepth = entry.nestLevel + } + if (currentDepth == 0) break + } + return ancestors +} + +private fun desktopVisiblePdfTocEntries( + toc: List, + expandedIndices: Set +): List> { + val result = mutableListOf>() + val visibilityStack = BooleanArray(50) { false } + visibilityStack[0] = true + + toc.forEachIndexed { index, entry -> + val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex) + if (visibilityStack[depth]) { + result += index to entry + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = index in expandedIndices + } + } else if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = false + } + } + return result +} + +@Composable +private fun DesktopPdfTocTreeItem( + entry: PdfTocEntry, + selected: Boolean, + hasChildren: Boolean, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 46.dp) + .padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clickable(enabled = hasChildren) { onToggleExpand() }, + contentAlignment = Alignment.Center + ) { + if (hasChildren) { Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = "Jump back", - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(4.dp)) - Text( - backPage?.let { "Jump back p. ${it + 1}" } ?: "Jump back", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - TextButton( - onClick = onForward, - enabled = forwardPage != null, - modifier = Modifier.weight(1f) - ) { - Text( - forwardPage?.let { "Jump forward p. ${it + 1}" } ?: "Jump forward", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.width(4.dp)) - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Jump forward", - modifier = Modifier.size(18.dp) + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } + Text( + entry.title, + fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + "p. ${entry.pageIndex + 1}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@Composable +private fun DesktopPdfNavigationEmpty(message: String) { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun DesktopPdfThumbnailTile( + document: DesktopPdfDocument, + pageIndex: Int, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + var thumbnail by remember(document.path, pageIndex) { mutableStateOf(null) } + var renderFailed by remember(document.path, pageIndex) { mutableStateOf(false) } + val pageSize = document.pageSizes.getOrNull(pageIndex) + val thumbnailScale = remember(pageSize) { + val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f + (120f / width).coerceIn(0.08f, 0.35f) + } + + LaunchedEffect(document.path, pageIndex, thumbnailScale) { + thumbnail = null + renderFailed = false + val rendered = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage( + document = document, + pageIndex = pageIndex, + scale = thumbnailScale, + renderAnnotations = false + ) + }.getOrNull() + } + thumbnail = rendered + renderFailed = rendered == null + } + + Surface( + modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + ) + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val render = thumbnail + if (render != null) { + Image( + bitmap = render.image, + contentDescription = "Page ${pageIndex + 1}", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(3.dp) + ) + } else { + Text( + if (renderFailed) "!" else "${pageIndex + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = "${pageIndex + 1}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) } } } @@ -5580,6 +8731,7 @@ private fun DesktopVerticalPdfPage( selectedEmbeddedAnnotationId: String?, selectedTool: PdfInkTool, selectedColor: Int, + highlighterPalette: List, strokeWidth: Float, isHighlighterSnapEnabled: Boolean, activeTextDraft: SharedPdfTextDraft?, @@ -5587,18 +8739,21 @@ private fun DesktopVerticalPdfPage( isRichTextMode: Boolean, readerAiFeaturesAvailable: Boolean, cloudTtsAvailable: Boolean, + externalLookupAvailable: Boolean, themeStyle: DesktopPdfThemeStyle, shouldRender: Boolean, + zoomPreview: DesktopPdfZoomPreview?, + zoomViewportRootOffset: Offset, + showPageNumberOverlay: Boolean = true, onSelectPage: (Int) -> Unit, onCopySelection: (DesktopPdfTextSelection) -> Unit, - onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize) -> Unit, - onSearchSelection: (DesktopPdfTextSelection) -> Unit, - onWebSearchSelection: (DesktopPdfTextSelection) -> Unit, - onDictionarySelection: (DesktopPdfTextSelection) -> Unit, + onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize, Int) -> Unit, + onExternalSearchSelection: (DesktopPdfTextSelection) -> Unit, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onDefineSelection: (DesktopPdfTextSelection) -> Unit, onSpeakSelection: (DesktopPdfTextSelection) -> Unit, - onTranslateSelection: (DesktopPdfTextSelection) -> Unit, onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit, + onAnnotationSelected: (SharedPdfAnnotation?) -> Unit, onLinkActivated: (DesktopPdfLinkTarget) -> Unit, onAnnotationAdded: (SharedPdfAnnotation) -> Unit, onAnnotationUpdated: (SharedPdfAnnotation) -> Unit, @@ -5606,21 +8761,25 @@ private fun DesktopVerticalPdfPage( onTextAnnotationSelected: (SharedPdfAnnotation) -> Unit, onTextDraftStarted: (Int, Offset, IntSize) -> Unit, onTextDraftChanged: (String, IntSize) -> Unit, - onTextDraftBoundsChanged: (PdfPageBounds) -> Unit + onTextDraftBoundsChanged: (PdfPageBounds) -> Unit, + onPan: (Offset) -> Unit, + onPagePositioned: (Int, Offset) -> Unit ) { val density = LocalDensity.current - val pageInteractionSource = remember { MutableInteractionSource() } - var renderedPage by remember(document.path, pageIndex, scale) { mutableStateOf(null) } - var renderError by remember(document.path, pageIndex, scale) { mutableStateOf(null) } - var isRendering by remember(document.path, pageIndex, scale) { mutableStateOf(true) } - var pageCanvasSize by remember(document.path, pageIndex, scale) { mutableStateOf(IntSize.Zero) } + var renderedPage by remember(document.path, pageIndex) { mutableStateOf(null) } + var renderError by remember(document.path, pageIndex) { mutableStateOf(null) } + var isRendering by remember(document.path, pageIndex) { mutableStateOf(true) } + var pageCanvasSize by remember(document.path, pageIndex) { mutableStateOf(IntSize.Zero) } + var pageRootOffset by remember(document.path, pageIndex) { mutableStateOf(Offset.Zero) } var selectionStartIndex by remember(document.path, pageIndex) { mutableStateOf(null) } var selectionEndIndex by remember(document.path, pageIndex) { mutableStateOf(null) } var selectionStartHit by remember(document.path, pageIndex) { mutableStateOf(null) } var selectionEndHit by remember(document.path, pageIndex) { mutableStateOf(null) } var textSelection by remember(document.path, pageIndex) { mutableStateOf(null) } var selectionMenuOffset by remember(document.path, pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(document.path, pageIndex) { mutableStateOf(null) } var activeStroke by remember(document.path, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(document.path, pageIndex, selectedTool) { mutableStateOf(null) } val currentTextSelection by rememberUpdatedState(textSelection) val currentAnnotations by rememberUpdatedState(annotations) @@ -5631,11 +8790,13 @@ private fun DesktopVerticalPdfPage( selectionEndHit = null textSelection = null selectionMenuOffset = null + activeSelectionHandle = null } fun clearInteractionState() { clearSelection() activeStroke = emptyList() + eraserPosition = null } LaunchedEffect(document.path, pageIndex, scale, shouldRender) { @@ -5646,7 +8807,10 @@ private fun DesktopVerticalPdfPage( clearInteractionState() return@LaunchedEffect } - isRendering = true + val hasPageRender = renderedPage != null + if (!hasPageRender) { + isRendering = true + } renderError = null val pageSize = document.pageSizes.getOrNull(pageIndex) if (pageSize == null) { @@ -5655,12 +8819,13 @@ private fun DesktopVerticalPdfPage( isRendering = false return@LaunchedEffect } - delay(45) + delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L) + isRendering = true val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) val result = withContext(Dispatchers.IO) { runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } } - renderedPage = result.getOrNull() + result.getOrNull()?.let { renderedPage = it } renderError = result.exceptionOrNull()?.message ?: if (renderedPage == null) "Failed to render page." else null isRendering = false @@ -5671,24 +8836,21 @@ private fun DesktopVerticalPdfPage( clearSelection() } else { activeStroke = emptyList() + eraserPosition = null } } LaunchedEffect(selectedTool) { activeStroke = emptyList() + eraserPosition = null } Column( - modifier = Modifier.clickable( - interactionSource = pageInteractionSource, - indication = null, - onClick = { onSelectPage(pageIndex) } - ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp) ) { val pageSize = document.pageSizes.getOrNull(pageIndex) - val placeholderScale = pageSize?.let { zoomSpec.safeRenderScale(it.width, it.height, scale) } ?: scale + val placeholderScale = zoomSpec.clamp(scale) val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() } val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() } val renderedPageWidth = renderedPage?.width ?: 0 @@ -5705,8 +8867,19 @@ private fun DesktopVerticalPdfPage( Box( modifier = Modifier .size(placeholderWidthDp, placeholderHeightDp) - .background(Color.White, RoundedCornerShape(2.dp)) + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + pageRootOffset = rootOffset + onPagePositioned(pageIndex, rootOffset) + } .onSizeChanged { pageCanvasSize = it } + .desktopPdfDocumentZoomPreviewLayer( + preview = zoomPreview, + currentZoom = scale, + viewportRootOffset = zoomViewportRootOffset, + pageRootOffset = pageRootOffset + ) + .background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { if (isRichTextMode) return@pointerInput awaitPointerEventScope { @@ -5714,6 +8887,22 @@ private fun DesktopVerticalPdfPage( val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { + currentAnnotations.asReversed().firstOrNull { + it.isDesktopTextSelectionHighlight && + it.pageIndex == pageIndex && + it.sharedPdfHitTest(point, pageCanvasSize) + } + } else { + null + } + if (highlightHit != null) { + onSelectPage(pageIndex) + onAnnotationSelected(highlightHit) + clearInteractionState() + event.changes.forEach { it.consume() } + continue + } if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { @@ -5760,6 +8949,62 @@ private fun DesktopVerticalPdfPage( } } } + .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) { + if (isRichTextMode || !isTextSelectionMode) return@pointerInput + detectTapGestures( + onLongPress = { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + if (selection != null) { + onSelectPage(pageIndex) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } + ) + } + .pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) { + if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val pointerId = down.id + var dragStarted = false + var dragDistance = 0f + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: return@awaitEachGesture + if (change.changedToUp()) { + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val delta = change.positionChange() + if (!dragStarted) { + dragDistance += delta.getDistance() + if (dragDistance <= viewConfiguration.touchSlop) { + continue + } + dragStarted = true + change.consume() + continue + } + onPan(delta) + change.consume() + } + } + } .pointerInput( pageIndex, isTextSelectionMode, @@ -5776,46 +9021,81 @@ private fun DesktopVerticalPdfPage( if (renderedPageWidth > 0 && renderedPageHeight > 0) { if (isRichTextMode) return@pointerInput if (isTextSelectionMode) { + var latestSelectionDragPoint: Offset? = null + var lastSelectionPreviewAt = 0L detectDragGestures( onDragStart = { start -> + latestSelectionDragPoint = start + lastSelectionPreviewAt = 0L onSelectPage(pageIndex) activeStroke = emptyList() selectionMenuOffset = null + val existingSelection = textSelection + val handle = existingSelection?.handleAt(start, pageCanvasSize) + activeSelectionHandle = handle val hit = document.charHitAt(pageIndex, start, pageCanvasSize) - selectionStartHit = hit - selectionStartIndex = hit?.index - selectionEndHit = null - selectionEndIndex = null + if (handle != null && existingSelection != null) { + selectionStartHit = null + selectionStartIndex = when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.endIndex + DesktopPdfSelectionHandle.END -> existingSelection.startIndex + } + selectionEndHit = hit + selectionEndIndex = hit?.index ?: when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.startIndex + DesktopPdfSelectionHandle.END -> existingSelection.endIndex + } + } else { + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + textSelection = null + } logPdfSelection( "drag_start page=${pageIndex + 1} " + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + "handle=${handle?.name ?: "none"} " + hit.formatLogHit("start") ) - textSelection = null }, onDrag = { change, _ -> - val startIndex = selectionStartIndex - val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) - selectionEndHit = hit - val endIndex = hit?.index - val previousEndIndex = selectionEndIndex - selectionEndIndex = endIndex - if (endIndex != previousEndIndex || textSelection == null) { - textSelection = if (startIndex != null && endIndex != null) { - document.selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = pageCanvasSize, - useNativeBounds = false - ) - } else { - null + latestSelectionDragPoint = change.position + val now = System.currentTimeMillis() + if (lastSelectionPreviewAt == 0L || + now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis + ) { + lastSelectionPreviewAt = now + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionPreviewBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize + ) + } else { + null + } } } + change.consume() }, onDragEnd = { + val finalHit = latestSelectionDragPoint + ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } + ?: selectionEndHit + if (finalHit != null) { + selectionEndHit = finalHit + selectionEndIndex = finalHit.index + } val startIndex = selectionStartIndex val endIndex = selectionEndIndex val selection = if (startIndex != null && endIndex != null) { @@ -5825,13 +9105,15 @@ private fun DesktopVerticalPdfPage( endIndex = endIndex, canvasSize = pageCanvasSize, useNativeBounds = true - )?.also { - textSelection = it - selectionMenuOffset = selectionEndHit?.point ?: selectionStartHit?.point - } + ) } else { - textSelection + textSelection?.takeIf { it.text.isNotBlank() } } + textSelection = selection + selectionMenuOffset = selection?.menuAnchor( + pageCanvasSize, + finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point + ) logPdfSelection( "drag_end page=${pageIndex + 1} " + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + @@ -5847,6 +9129,9 @@ private fun DesktopVerticalPdfPage( selectionEndIndex = null selectionStartHit = null selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L }, onDragCancel = { logPdfSelection( @@ -5860,6 +9145,9 @@ private fun DesktopVerticalPdfPage( selectionEndIndex = null selectionStartHit = null selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L } ) } else if (selectedTool == PdfInkTool.TEXT) { @@ -5884,34 +9172,79 @@ private fun DesktopVerticalPdfPage( } } ) - } else { + } else if (selectedTool != PdfInkTool.NONE) { var eraserPreviousPoint: Offset? = null - detectDragGestures( - onDragStart = { start -> - onSelectPage(pageIndex) - clearInteractionState() - if (selectedTool == PdfInkTool.ERASER) { - val annotationSnapshot = currentAnnotations - val updatedAnnotations = annotationSnapshot.filterNot { - it.pageIndex == pageIndex && it.sharedPdfHitTest( - point = start, - size = pageCanvasSize, - eraserStrokeWidth = strokeWidth - ) - } - if (updatedAnnotations.size != annotationSnapshot.size) { - onAnnotationsChanged(updatedAnnotations) - } - eraserPreviousPoint = start - } else { - activeStroke = listOf( - start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis()) + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val start = down.position + onSelectPage(pageIndex) + clearInteractionState() + if (selectedTool == PdfInkTool.ERASER) { + eraserPosition = start + val annotationSnapshot = currentAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth ) } - }, - onDrag = { change, _ -> + if (updatedAnnotations.size != annotationSnapshot.size) { + onAnnotationsChanged(updatedAnnotations) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf( + start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis()) + ) + } + + val pointerId = down.id + var dragStarted = false + while (true) { + val event = awaitPointerEvent() + if (event.changes.size > 1) { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + val change = event.changes.firstOrNull { it.id == pointerId } + ?: run { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (change.changedToUp()) { + change.consume() + if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { + onAnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) + ) + } + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val distance = (change.position - start).getDistance() + if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue + dragStarted = true if (selectedTool == PdfInkTool.ERASER) { val point = change.position + eraserPosition = point val previousPoint = eraserPreviousPoint val annotationSnapshot = currentAnnotations val updatedAnnotations = annotationSnapshot.filterNot { @@ -5935,30 +9268,9 @@ private fun DesktopVerticalPdfPage( timestamp = System.currentTimeMillis() ) } - }, - onDragEnd = { - eraserPreviousPoint = null - if (activeStroke.size > 1) { - onAnnotationAdded( - SharedPdfAnnotation( - id = "ink_${System.currentTimeMillis()}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.INK, - tool = selectedTool, - points = activeStroke, - colorArgb = selectedColor, - strokeWidth = strokeWidth, - createdAt = System.currentTimeMillis() - ) - ) - } - activeStroke = emptyList() - }, - onDragCancel = { - eraserPreviousPoint = null - activeStroke = emptyList() + change.consume() } - ) + } } } }, @@ -5968,8 +9280,6 @@ private fun DesktopVerticalPdfPage( !shouldRender -> { Text("Page ${pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) } - isRendering -> CircularProgressIndicator() - renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) renderedPage != null -> { val pageRender = renderedPage!! val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { @@ -6004,7 +9314,7 @@ private fun DesktopVerticalPdfPage( ) { val queryLength = searchQuery.trim().length if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) { - emptyList() + emptyList() } else { SharedPdfSearchEngine.highlightsForPage( results = searchResults, @@ -6061,7 +9371,7 @@ private fun DesktopVerticalPdfPage( pageWidth = pageCanvasSize.width.toFloat(), pageHeight = pageCanvasSize.height.toFloat(), isTextEditingEnabled = isRichTextMode, - onPageTapped = { onSelectPage(pageIndex) } + onPageTapped = {} ) PdfSearchHighlightOverlay( bounds = searchHighlightBounds, @@ -6087,7 +9397,15 @@ private fun DesktopVerticalPdfPage( activeTool = selectedTool, activeStrokeColorArgb = selectedColor, activeStrokeWidth = strokeWidth, - selectedAnnotationId = selectedAnnotationId + selectedAnnotationId = selectedAnnotationId, + eraserPosition = eraserPosition, + showEraserIndicator = selectedTool == PdfInkTool.ERASER, + eraserStrokeWidth = strokeWidth + ) + PdfTextSelectionHandles( + selection = textSelection, + canvasSize = pageCanvasSize, + activeHandle = activeSelectionHandle ) SharedPdfInlineTextEditorOverlay( draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, @@ -6118,10 +9436,12 @@ private fun DesktopVerticalPdfPage( canvasSize = pageCanvasSize, selectedAnnotationId = selectedEmbeddedAnnotationId ) - SharedPdfPageNumberOverlay( - pageIndex = pageIndex, - pageCount = document.pageCount - ) + if (showPageNumberOverlay) { + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + } if (textSelection != null && selectionMenuOffset != null) { Box( modifier = Modifier @@ -6137,43 +9457,36 @@ private fun DesktopVerticalPdfPage( selection = textSelection, menuOffset = selectionMenuOffset, canvasSize = pageCanvasSize, + highlighterPalette = highlighterPalette, + onHighlighterPaletteChange = onHighlighterPaletteChange, onCopy = { textSelection?.let(onCopySelection) clearSelection() }, - onHighlight = { - textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize) } + onHighlight = { colorArgb -> + textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize, colorArgb) } clearSelection() }, onSearch = { - textSelection?.let(onSearchSelection) - selectionMenuOffset = null - }, - onWebSearch = { - textSelection?.let(onWebSearchSelection) - selectionMenuOffset = null - }, - onDictionary = { - textSelection?.let(onDictionarySelection) - selectionMenuOffset = null + textSelection?.let(onExternalSearchSelection) + clearSelection() }, onDefine = { textSelection?.let(onDefineSelection) - selectionMenuOffset = null + clearSelection() }, onSpeak = { textSelection?.let(onSpeakSelection) - selectionMenuOffset = null - }, - onTranslate = { - textSelection?.let(onTranslateSelection) - selectionMenuOffset = null + clearSelection() }, showDefine = readerAiFeaturesAvailable, showSpeak = cloudTtsAvailable, + showSearch = externalLookupAvailable, onClear = ::clearSelection ) } + isRendering -> CircularProgressIndicator() + renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) } } } @@ -6184,14 +9497,27 @@ private fun DesktopPdfAnnotationEditor( annotation: SharedPdfAnnotation, onUpdate: (SharedPdfAnnotation) -> Unit, onDelete: () -> Unit, - onClose: () -> Unit + onClose: () -> Unit, + onCopy: () -> Unit, + showSearch: Boolean, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + onSearch: () -> Unit ) { + val highlighterColors = remember(highlighterPalette) { + SharedPdfAndroidHighlightColors.palette + } + var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } + val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || + annotation.tool == PdfInkTool.HIGHLIGHTER || + annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + Surface( color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), + shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( "Selected ${annotation.desktopLabel()}", @@ -6208,13 +9534,57 @@ private fun DesktopPdfAnnotationEditor( color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) + if (annotation.text.isNotBlank()) { + Surface( + color = Color(annotation.colorArgb).copy(alpha = 0.10f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.heightIn(min = 72.dp)) { + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .background(Color(annotation.colorArgb)) + ) + Text( + "\"${annotation.text}\"", + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), + modifier = Modifier.padding(14.dp) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + DesktopBottomSheetToolButton( + icon = Icons.Default.ContentCopy, + label = "Copy", + onClick = onCopy + ) + if (showSearch) { + DesktopBottomSheetToolButton( + icon = Icons.Default.Search, + label = "Search", + onClick = onSearch + ) + } + } + } if (annotation.kind == PdfAnnotationKind.TEXT) { - OutlinedTextField( + SharedStableOutlinedTextField( value = annotation.text, onValueChange = { onUpdate(annotation.copy(text = it)) }, label = { Text("Text note") }, minLines = 2, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + selectionKey = annotation.id ) SharedPdfTextStyleControls( style = annotation.sharedPdfTextStyle(), @@ -6222,28 +9592,72 @@ private fun DesktopPdfAnnotationEditor( ) } if (annotation.kind != PdfAnnotationKind.TEXT) { - val palette = if ( - annotation.kind == PdfAnnotationKind.HIGHLIGHT || - annotation.tool == PdfInkTool.HIGHLIGHTER || - annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND - ) { - SharedPdfAnnotationDefaults.highlighterPalette + val palette = if (isHighlighterAnnotation) { + highlighterColors } else { SharedPdfAnnotationDefaults.penPalette } Text("Color", style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - palette.forEach { argb -> + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + palette.forEachIndexed { _, argb -> Surface( modifier = Modifier .size(26.dp) - .clickable { onUpdate(annotation.copy(colorArgb = argb)) }, + .clickable { + val nextColor = if (isHighlighterAnnotation) { + SharedPdfAndroidHighlightColors.nearestArgb(argb) + } else { + argb + } + onUpdate(annotation.copy(colorArgb = nextColor)) + }, color = Color(argb), shape = RoundedCornerShape(13.dp), content = {} ) } + if (isHighlighterAnnotation) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) + .clickable { + editingHighlighterSlot = highlighterColors + .indexOf(annotation.colorArgb) + .takeIf { it >= 0 } + ?: 0 + } + ) + } } + SharedStableOutlinedTextField( + value = annotation.note.orEmpty(), + onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, + label = { Text("Note") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = annotation.id + ) } if (annotation.kind == PdfAnnotationKind.INK) { val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() @@ -6262,6 +9676,86 @@ private fun DesktopPdfAnnotationEditor( } } } + editingHighlighterSlot?.let { requestedSlot -> + val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex) + val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f) + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = "Highlight color ${slot + 1}", + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb) + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(highlighterColors).withColorAt( + slotIndex = slot, + colorArgb = nextArgb + ) + ) + onUpdate(annotation.copy(colorArgb = syncedArgb)) + editingHighlighterSlot = null + } + ) { liveColor -> + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + highlighterColors.forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = RoundedCornerShape(21.dp) + ) + .clickable { editingHighlighterSlot = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun DesktopBottomSheetToolButton( + icon: ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp) + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } @Composable @@ -6353,6 +9847,72 @@ private data class DesktopPdfTextSelection( val endIndex: Int ) +private data class DesktopPdfSelectionCanvasBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val centerX: Float get() = (left + right) / 2f +} + +private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? { + val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top } + if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfSelectionCanvasBounds( + left = validBounds.minOf { it.left } * canvasSize.width, + top = validBounds.minOf { it.top } * canvasSize.height, + right = validBounds.maxOf { it.right } * canvasSize.width, + bottom = validBounds.maxOf { it.bottom } * canvasSize.height + ) +} + +private fun DesktopPdfTextSelection.menuAnchor( + canvasSize: IntSize, + fallback: Offset? +): Offset { + val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero + return Offset(x = bounds.centerX, y = bounds.top) +} + +private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = first.left * canvasSize.width, + y = first.bottom * canvasSize.height + ) +} + +private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = last.right * canvasSize.width, + y = last.bottom * canvasSize.height + ) +} + +private fun DesktopPdfTextSelection.handleAt( + point: Offset, + canvasSize: IntSize +): DesktopPdfSelectionHandle? { + val start = startHandleOffset(canvasSize) + val end = endHandleOffset(canvasSize) + + fun Offset.containsHandlePoint(): Boolean { + val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f + return point.x in (x - halfWidth)..(x + halfWidth) && + point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx) + } + + return when { + start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START + end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END + else -> null + } +} + private data class DesktopPdfCharHit( val index: Int, val source: String, @@ -6368,6 +9928,23 @@ private fun SharedPdfAnnotation.desktopLabel(): String { } } +private fun SharedPdfAnnotation.desktopSheetTitle(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> "Highlight" + PdfAnnotationKind.INK -> "Annotation" + PdfAnnotationKind.TEXT -> "Text note" + } +} + +private fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection { + return DesktopPdfTextSelection( + text = text, + lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) }, + startIndex = rangeStartIndex ?: 0, + endIndex = rangeEndIndex ?: text.length + ) +} + private fun SharedPdfEmbeddedAnnotation.threadText(): String { return buildString { append(author.ifBlank { "Unknown" }) @@ -6444,61 +10021,319 @@ private fun PdfTextSelectionOverlay( } } +@Composable +private fun PdfTextSelectionHandles( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize, + activeHandle: DesktopPdfSelectionHandle? +) { + selection ?: return + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + val density = LocalDensity.current + val handleSize = 24.dp + val handleWidthPx = with(density) { handleSize.toPx() } + val start = selection.startHandleOffset(canvasSize) + val end = selection.endHandleOffset(canvasSize) + val handleColor = MaterialTheme.colorScheme.primary + + fun Modifier.handleOffset(position: Offset): Modifier = offset { + IntOffset( + x = (position.x - handleWidthPx / 2f).roundToInt(), + y = position.y.roundToInt() + ) + } + + Box(Modifier.fillMaxSize()) { + start?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = "Selection start handle", + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = 30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + end?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = "Selection end handle", + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = -30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + } +} + +private object DesktopPdfSelectionMenuIcons { + val Copy = vector( + name = "DesktopPdfSelectionCopy", + pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" + ) + val Dictionary = vector( + name = "DesktopPdfSelectionDictionary", + pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z" + ) + val Search = vector( + name = "DesktopPdfSelectionSearch", + pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" + ) + val Teardrop = vector( + name = "DesktopPdfSelectionTeardrop", + pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" + ) + + private fun vector(name: String, pathData: String): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + }.build() + } +} + +private enum class DesktopPdfSelectionHandle { + START, + END +} + @Composable private fun PdfSelectionMenu( selection: DesktopPdfTextSelection?, menuOffset: Offset?, canvasSize: IntSize, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onCopy: () -> Unit, - onHighlight: () -> Unit, + onHighlight: (Int) -> Unit, onSearch: () -> Unit, - onWebSearch: () -> Unit, - onDictionary: () -> Unit, onDefine: () -> Unit, onSpeak: () -> Unit, - onTranslate: () -> Unit, showDefine: Boolean, showSpeak: Boolean, + showSearch: Boolean, onClear: () -> Unit ) { selection ?: return val anchor = menuOffset ?: return - Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - shadowElevation = 8.dp, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.padding( - start = anchor.x.coerceIn( - PdfSelectionMenuMarginPx, - (canvasSize.width.toFloat() - PdfSelectionMenuWidthPx).coerceAtLeast(PdfSelectionMenuMarginPx) - ).dp, - top = anchor.y.coerceIn( - PdfSelectionMenuMarginPx, - (canvasSize.height.toFloat() - PdfSelectionMenuHeightPx).coerceAtLeast(PdfSelectionMenuMarginPx) - ).dp - ) - ) { - Row( - modifier = Modifier - .padding(horizontal = 6.dp, vertical = 4.dp) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically + val selectionBounds = selection.canvasBounds(canvasSize) + val paletteColors = remember(highlighterPalette) { + SharedPdfAndroidHighlightColors.palette + } + var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf(null) + } + val actions = buildList { + add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy)) + if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) + if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak)) + if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch)) + add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true)) + } + val estimatedHeight = PdfSelectionMenuPaletteHeightPx + + (((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx) + val left = (anchor.x - (PdfSelectionMenuWidthPx / 2f)).coerceIn( + PdfSelectionMenuMarginPx, + (canvasSize.width.toFloat() - PdfSelectionMenuWidthPx).coerceAtLeast(PdfSelectionMenuMarginPx) + ) + val selectionTop = selectionBounds?.top ?: anchor.y + val selectionBottom = selectionBounds?.bottom ?: anchor.y + val preferredTop = selectionTop - estimatedHeight - PdfSelectionMenuAnchorGapPx + val fallbackTop = selectionBottom + PdfSelectionMenuAnchorGapPx + val hasRoomAbove = preferredTop >= PdfSelectionMenuMarginPx + val pageHeight = canvasSize.height.toFloat() + val hasRoomBelow = fallbackTop + estimatedHeight <= pageHeight - PdfSelectionMenuMarginPx + val rawTop = when { + hasRoomAbove -> preferredTop + hasRoomBelow -> fallbackTop + selectionTop > pageHeight - selectionBottom -> preferredTop + else -> fallbackTop + } + val top = rawTop.coerceIn( + PdfSelectionMenuMarginPx, + (canvasSize.height.toFloat() - estimatedHeight).coerceAtLeast(PdfSelectionMenuMarginPx) + ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 10.dp, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.offset { + IntOffset(left.roundToInt(), top.roundToInt()) + } ) { - TextButton(onClick = onCopy) { Text("Copy") } - TextButton(onClick = onHighlight) { Text("Highlight") } - if (showDefine) TextButton(onClick = onDefine) { Text("Define") } - if (showSpeak) TextButton(onClick = onSpeak) { Text("Speak") } - TextButton(onClick = onDictionary) { Text("Dict") } - TextButton(onClick = onSearch) { Text("Find") } - TextButton(onClick = onWebSearch) { Text("Web") } - TextButton(onClick = onTranslate) { Text("Translate") } - TextButton(onClick = onClear) { Text("Clear") } + Column( + modifier = Modifier + .widthIn(min = 200.dp, max = 240.dp) + .padding(bottom = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + paletteColors.forEach { colorArgb -> + Surface( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(32.dp) + .clickable { onHighlight(colorArgb) }, + color = Color(colorArgb), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)), + content = {} + ) + } + Box( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) + .clickable { editingHighlighterSlot = 0 } + ) + } + HorizontalDivider() + actions.chunked(3).forEach { rowActions -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + rowActions.forEach { action -> + val tint = if (action.isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + Column( + modifier = Modifier + .width(64.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { action.onClick() } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = action.icon, + contentDescription = action.label, + tint = tint, + modifier = Modifier.size(24.dp) + ) + Text( + action.label, + style = MaterialTheme.typography.labelSmall, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + repeat(3 - rowActions.size) { + Spacer(modifier = Modifier.width(64.dp)) + } + } + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex) + val initialColor = Color(paletteColors[slot]).copy(alpha = 1f) + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = "Highlight color ${slot + 1}", + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(paletteColors).withColorAt( + slotIndex = slot, + colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + ) + ) + editingHighlighterSlot = null + } + ) { liveColor -> + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + paletteColors.forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = RoundedCornerShape(21.dp) + ) + .clickable { editingHighlighterSlot = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } } } } +private data class PdfSelectionMenuAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val isDestructive: Boolean = false +) + private fun DesktopPdfDocument.charHitAt( pageIndex: Int, point: Offset, @@ -6538,24 +10373,96 @@ private fun DesktopPdfDocument.charHitAt( ) } +private fun DesktopPdfDocument.wordSelectionAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfTextSelection? { + val hit = charHitAt(pageIndex, point, canvasSize) ?: return null + if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) { + return null + } + val pageText = textPageData(pageIndex).text + if (pageText.isEmpty()) return null + val hitIndex = hit.index.coerceIn(0, pageText.lastIndex) + if (!pageText[hitIndex].isDesktopPdfWordPart()) return null + var startIndex = hitIndex + while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) { + startIndex -= 1 + } + var endIndex = hitIndex + while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) { + endIndex += 1 + } + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = true + ) +} + +private fun DesktopPdfDocument.isPointNearTextChar( + pageIndex: Int, + charIndex: Int, + point: PdfNormalizedPoint +): Boolean { + val charBounds = textPageData(pageIndex).chars + .visiblePdfTextBounds() + .firstOrNull { it.index == charIndex } + ?: return false + val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f) + val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f) + return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) && + point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding) +} + +private fun Char.isDesktopPdfWordPart(): Boolean { + return isLetterOrDigit() || this == '\'' || this == '-' || this == '_' +} + +private fun DesktopPdfDocument.selectionPreviewBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize +): DesktopPdfTextSelection? { + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = false, + includeText = false + ) +} + private fun DesktopPdfDocument.selectionBetweenIndexes( pageIndex: Int, startIndex: Int, endIndex: Int, canvasSize: IntSize, - useNativeBounds: Boolean = true + useNativeBounds: Boolean = true, + includeText: Boolean = true ): DesktopPdfTextSelection? { val chars = textPageData(pageIndex).chars - if (chars.isEmpty() || abs(startIndex - endIndex) < 1) return null + if (chars.isEmpty()) return null val firstIndex = minOf(startIndex, endIndex) val lastIndex = maxOf(startIndex, endIndex) val selectedChars = chars.filter { it.index in firstIndex..lastIndex } - val text = selectedChars.joinToString("") { it.char.toString() } - .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") - .replace(Regex("\\n{3,}"), "\n\n") - .trim() - if (text.isBlank()) return null + if (selectedChars.isEmpty()) return null + val text = if (includeText) { + selectedChars.joinToString("") { it.char.toString() } + .replace(DesktopPdfSelectionInlineWhitespaceRegex, " ") + .replace(DesktopPdfSelectionBlankLinesRegex, "\n\n") + .trim() + } else { + "" + } + if (includeText && text.isBlank()) return null val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) + if (!includeText && fallbackBounds.isEmpty()) return null val nativeBounds = if (useNativeBounds) { DesktopPdfium.textRectsForRange( document = this, @@ -6666,36 +10573,44 @@ private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { ) } -private const val PdfSelectionMenuWidthPx = 620f -private const val PdfSelectionMenuHeightPx = 54f +private const val DesktopPdfSelectionPreviewThrottleMillis = 32L +private const val DesktopPdfZoomGestureFrameMillis = 16L +private const val DesktopPdfZoomCommitDebounceMillis = 180L +private const val DesktopPdfZoomRenderDebounceMillis = 300L +private const val DesktopPdfViewportPersistDebounceMillis = 300L +private const val DesktopPdfPaginationPrefetchDelayMillis = 450L +internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f +private const val DesktopPdfRenderScaleTolerance = 0.01f +private const val DesktopPdfPaginationRenderCacheRadius = 2 +private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+") +private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}") +private const val PdfSelectionMenuWidthPx = 240f +private const val PdfSelectionMenuPaletteHeightPx = 62f +private const val PdfSelectionMenuActionRowHeightPx = 76f +private const val PdfSelectionMenuAnchorGapPx = 16f private const val PdfSelectionMenuMarginPx = 6f +private const val DesktopPdfSelectionHandleTouchWidthPx = 44f +private const val DesktopPdfSelectionHandleTouchTopPx = 8f +private const val DesktopPdfSelectionHandleTouchBottomPx = 40f internal fun desktopPdfAnnotationFile(documentPath: String): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(baseDir, "Episteme/annotations/pdf_$safeName.json") + return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json") } internal fun desktopPdfBookmarkFile(documentPath: String): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(baseDir, "Episteme/annotations/pdf_${safeName}_bookmarks.json") + return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json") } internal fun desktopPdfRichTextFile(documentPath: String): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(baseDir, "Episteme/annotations/pdf_${safeName}_rich_text.json") + return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json") } private fun desktopPdfSearchIndexFile(documentPath: String): File { - val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(baseDir, "Episteme/search/pdf_${safeName}_text_index.tsv") + return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") } private fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { @@ -6762,8 +10677,8 @@ private fun ReaderScreen( session: ReaderSessionState, readerEngine: ReaderEngine, onSessionChange: (ReaderSessionState) -> Unit, - onOpenBook: () -> Unit, - onOpenPdf: () -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + onFullscreenChange: (Boolean) -> Unit = {}, toolbarPreferences: ReaderToolbarPreferences, onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, highlightPalette: ReaderHighlightPalette, @@ -6775,8 +10690,11 @@ private fun ReaderScreen( customFonts: List, readerExtrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, + externalLookupAvailable: Boolean, + cloudTtsControlsAvailable: Boolean, onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, onAiAction: (ReaderAiFeature, String) -> Unit, + onAiResultDismiss: () -> Unit, onCloudTtsToggle: (String) -> Unit, onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, onCloudTtsPauseResume: () -> Unit, @@ -6786,22 +10704,251 @@ private fun ReaderScreen( readerTextureDataUri: (String) -> String?, readerCustomTextureIds: List, onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, - webViewRuntimeState: DesktopWebViewRuntimeState + webViewRuntimeState: DesktopWebViewRuntimeState, + webViewNetworkAccessEnabled: Boolean, + epubPaginationCache: SharedEpubPaginationCache, + epubPaginationCacheGeneration: Int ) { + val clipboardManager = LocalClipboardManager.current + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val paginationCacheWriteScope = rememberCoroutineScope() + val measuredPaginator = remember( + textMeasurer, + density, + session.reader.settings.fontFamily, + session.reader.settings.customFontPath, + epubPaginationCache, + paginationCacheWriteScope + ) { + SharedMeasuredEpubPaginator( + textMeasurer = textMeasurer, + density = density, + fontFamily = session.reader.settings.toDesktopReaderFontFamily(), + pageCache = epubPaginationCache, + cacheWriteScope = paginationCacheWriteScope + ) + } + var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } + val paginationLayoutSignature = session.reader.settings.layoutSignature() + val paginationContentSignature = remember(session.reader.book) { + session.reader.book.desktopPaginationContentSignature() + } + val paginationDensitySignature = DesktopEpubPaginationDensity( + density = density.density, + fontScale = density.fontScale + ) + val measuredPaginationRequest = remember( + session.reader.book.id, + paginationContentSignature, + paginationLayoutSignature, + readerViewport, + paginationDensitySignature, + epubPaginationCacheGeneration + ) { + if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) { + DesktopEpubPaginationRequest( + bookId = session.reader.book.id, + chapterSignature = paginationContentSignature, + layoutSignature = paginationLayoutSignature, + viewport = readerViewport, + density = paginationDensitySignature, + cacheGeneration = epubPaginationCacheGeneration + ) + } else { + null + } + } + var completedMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var runningMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED || + (measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest) + val latestSession by rememberUpdatedState(session) + val latestOnSessionChange by rememberUpdatedState(onSessionChange) var externalLinkDialogUrl by remember { mutableStateOf(null) } var lastHandledLink by remember { mutableStateOf(null) } + var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) } + val currentReaderFullscreen by rememberUpdatedState(isFullscreen) + val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) + + fun setReaderFullscreen(enabled: Boolean) { + isFullscreen = enabled + onFullscreenChange(enabled) + } DesktopExternalLinkDialog( url = externalLinkDialogUrl, onDismiss = { externalLinkDialogUrl = null } ) + fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false + val currentSession = latestSession + when (action) { + DesktopReaderKeyNavigation.NEXT -> latestOnSessionChange(currentSession.reduce(ReaderAction.NextPage, readerEngine)) + DesktopReaderKeyNavigation.PREVIOUS -> latestOnSessionChange(currentSession.reduce(ReaderAction.PreviousPage, readerEngine)) + DesktopReaderKeyNavigation.FIRST -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToPage(0), readerEngine)) + DesktopReaderKeyNavigation.LAST -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToPage(currentSession.reader.pages.lastIndex), readerEngine)) + DesktopReaderKeyNavigation.SEARCH -> latestOnSessionChange(currentSession.reduce(ReaderAction.SearchOpened, readerEngine)) + DesktopReaderKeyNavigation.NEXT_SEARCH -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToNextSearchResult, readerEngine)) + DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> if (isFullscreen) setReaderFullscreen(false) + } + return true + } + DesktopReaderFullscreenKeyEffect( + enabled = isFullscreen && externalLinkDialogUrl == null, + onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } + ) + + LaunchedEffect(session.reader.settings.readingMode) { + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { + completedMeasuredPaginationRequest = null + runningMeasuredPaginationRequest = null + } + } + + DisposableEffect(session.reader.book.id) { + onDispose { + if (currentReaderFullscreen) { + currentOnReaderFullscreenChange(false) + } + } + } + + LaunchedEffect( + measuredPaginationRequest, + measuredPaginator + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + if (completedMeasuredPaginationRequest == request) { + logEpubPagination( + "reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + return@LaunchedEffect + } + delay(280L) + val settings = latestSession.reader.settings + if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect + runningMeasuredPaginationRequest = request + try { + val reflowStartSession = latestSession + val reflowStartRequestId = reflowStartSession.navigationRequestId + val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) + logEpubPagination( + "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + + "margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " + + "pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " + + "anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}" + ) + val pages = measuredPaginator.paginate( + book = session.reader.book, + settings = settings, + viewport = request.viewport + ) + val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages) + logEpubPagination( + "reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " + + "layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}" + ) + if (layoutChanged) { + latestOnSessionChange( + readerEngine.replacePages( + state = latestSession, + pages = pages, + reflowAnchor = reflowAnchor, + navigationRequestIdAtReflowStart = reflowStartRequestId + ) + ) + } + if (pages.isNotEmpty()) { + completedMeasuredPaginationRequest = request + } + } finally { + if (runningMeasuredPaginationRequest == request) { + runningMeasuredPaginationRequest = null + } + } + } + + val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text -> + val settings = aiByokSettings.sanitized() + when (action) { + DesktopReaderSelectionAction.DEFINE -> { + if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) + } + DesktopReaderSelectionAction.SPEAK -> { + if (settings.isCloudTtsAvailable) onCloudTtsToggle(text) + } + DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) + } + } + val nativeSelectionActions = buildSet { + val settings = aiByokSettings.sanitized() + if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE) + if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) + if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) + } + val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text -> + when (action) { + SharedNativeReaderSelectionAction.DEFINE -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text) + SharedNativeReaderSelectionAction.SPEAK -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text) + SharedNativeReaderSelectionAction.SEARCH -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text) + } + } + val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link -> + val now = System.currentTimeMillis() + val last = lastHandledLink + if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { + logEpubLink( + "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + + "ageMs=${now - last.handledAtMs}" + ) + } else { + lastHandledLink = DesktopEpubHandledLink(link.href, now) + logEpubLink( + "click source=${link.source} href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + + "text=\"${link.text.orEmpty().logPreview()}\"" + ) + when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { + is ReaderLinkTarget.External -> { + logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") + if (externalLookupAvailable) { + externalLinkDialogUrl = target.url + } + } + is ReaderLinkTarget.Internal -> { + logEpubLink( + "resolved_internal chapter=${target.locator.chapterIndex} " + + "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" + ) + onSessionChange(readerEngine.jumpToLocator(session, target.locator)) + } + ReaderLinkTarget.Ignored -> { + logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") + } + } + } + } + SharedReaderScreen( session = session, readerEngine = readerEngine, onSessionChange = onSessionChange, - onOpenBook = onOpenBook, - onOpenPdf = onOpenPdf, + onReturnToLibrary = onReturnToLibrary, + isFullscreen = isFullscreen, + onFullscreenChange = ::setReaderFullscreen, toolbarPreferences = toolbarPreferences, onToolbarPreferencesChange = onToolbarPreferencesChange, highlightPalette = highlightPalette, @@ -6813,8 +10960,12 @@ private fun ReaderScreen( customFonts = customFonts, readerExtrasState = readerExtrasState, aiByokSettings = aiByokSettings, + externalLookupAvailable = externalLookupAvailable, + cloudTtsControlsAvailable = cloudTtsControlsAvailable, onExternalLookup = onExternalLookup, onAiAction = onAiAction, + onAiResultDismiss = onAiResultDismiss, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, onCloudTtsStart = onCloudTtsStart, onCloudTtsPauseResume = onCloudTtsPauseResume, onCloudTtsStop = onCloudTtsStop, @@ -6823,104 +10974,187 @@ private fun ReaderScreen( readerTextureDataUri = readerTextureDataUri, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = onImportReaderTexture - ) { html, background, navigationTarget, highlights, onVisiblePageChanged -> + ) { renderPlan, onVisiblePageChanged, onHighlightSelected -> Surface( - color = background, + color = renderPlan.background, shape = RoundedCornerShape(8.dp), modifier = Modifier .fillMaxWidth() .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .onSizeChanged { size -> + val next = ReaderViewportSpec(size.width, size.height) + logReaderGap( + "desktop_epub_reader_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" + ) + if (next != readerViewport) { + logEpubPagination( + "viewport_changed width=${next.widthPx} height=${next.heightPx} " + + "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" + ) + readerViewport = next + } + } ) { - if (webViewRuntimeState.initialized) { - DesktopEpubWebView( - html = html, - navigationTarget = navigationTarget, - highlights = highlights, - onHighlightCreated = { highlight -> - onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) - }, - onSelectionAction = { action, text -> - val settings = aiByokSettings.sanitized() - when (action) { - DesktopReaderSelectionAction.DEFINE -> { - if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) - } - DesktopReaderSelectionAction.SPEAK -> { - if (settings.isCloudTtsAvailable) onCloudTtsToggle(text) - } - DesktopReaderSelectionAction.DICTIONARY -> onExternalLookup(ReaderExternalLookupAction.DICTIONARY, text) - DesktopReaderSelectionAction.TRANSLATE -> onExternalLookup(ReaderExternalLookupAction.TRANSLATE, text) - DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) - } - }, - onLinkClicked = { link -> - val now = System.currentTimeMillis() - val last = lastHandledLink - if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { - logEpubLink( - "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + - "ageMs=${now - last.handledAtMs}" - ) - } else { - lastHandledLink = DesktopEpubHandledLink(link.href, now) - logEpubLink( - "click source=${link.source} href=\"${link.href.logPreview()}\" " + - "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + - "text=\"${link.text.orEmpty().logPreview()}\"" - ) - when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { - is ReaderLinkTarget.External -> { - logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") - externalLinkDialogUrl = target.url - } - is ReaderLinkTarget.Internal -> { - logEpubLink( - "resolved_internal chapter=${target.locator.chapterIndex} " + - "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" - ) - onSessionChange(readerEngine.goToLocator(session, target.locator)) - } - ReaderLinkTarget.Ignored -> { - logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") - } - } - } - }, - onVisiblePageChanged = onVisiblePageChanged, + if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { + DesktopEpubPaginationPreparing( + active = runningMeasuredPaginationRequest != null, modifier = Modifier.fillMaxSize() ) } else { - DesktopWebViewRuntimeIndicator( - state = webViewRuntimeState, - modifier = Modifier.fillMaxSize() - ) + when (renderPlan) { + is ReaderContentRenderPlan.WebDocument -> { + if (webViewRuntimeState.initialized) { + DesktopEpubWebView( + html = renderPlan.html, + appearanceScript = renderPlan.appearanceScript, + navigationTarget = renderPlan.navigationTarget, + highlights = renderPlan.highlights, + onHighlightCreated = { highlight -> + onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) + }, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = { action -> + when (action) { + DesktopReaderKeyNavigation.NEXT -> onSessionChange(session.reduce(ReaderAction.NextPage, readerEngine)) + DesktopReaderKeyNavigation.PREVIOUS -> onSessionChange(session.reduce(ReaderAction.PreviousPage, readerEngine)) + DesktopReaderKeyNavigation.FIRST -> onSessionChange(session.reduce(ReaderAction.JumpToPage(0), readerEngine)) + DesktopReaderKeyNavigation.LAST -> onSessionChange(session.reduce(ReaderAction.JumpToPage(session.reader.pages.lastIndex), readerEngine)) + DesktopReaderKeyNavigation.SEARCH -> onSessionChange(session.reduce(ReaderAction.SearchOpened, readerEngine)) + DesktopReaderKeyNavigation.NEXT_SEARCH -> onSessionChange(session.reduce(ReaderAction.JumpToNextSearchResult, readerEngine)) + DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> if (isFullscreen) setReaderFullscreen(false) + } + }, + onSelectionAction = handleDesktopSelectionAction, + onLinkClicked = handleDesktopEpubLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + networkAccessEnabled = webViewNetworkAccessEnabled, + modifier = Modifier.fillMaxSize() + ) + } else { + DesktopWebViewRuntimeIndicator( + state = webViewRuntimeState, + modifier = Modifier.fillMaxSize() + ) + } + } + is ReaderContentRenderPlan.NativePaginatedPages -> { + SharedNativePaginatedReader( + renderPlan = renderPlan, + readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), + searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), + onVisiblePageChanged = onVisiblePageChanged, + enabledSelectionActions = nativeSelectionActions, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onSelectionAction = handleNativeSelectionAction, + onHighlightCreated = { highlight -> + onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + } } } } } +private data class DesktopEpubPaginationRequest( + val bookId: String, + val chapterSignature: Int, + val layoutSignature: ReaderLayoutSignature, + val viewport: ReaderViewportSpec, + val density: DesktopEpubPaginationDensity, + val cacheGeneration: Int +) + +private data class DesktopEpubPaginationDensity( + val density: Float, + val fontScale: Float +) + +private fun SharedEpubBook.desktopPaginationContentSignature(): Int { + return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> + 31 * acc + + chapter.id.hashCode() + + chapter.plainText.length + + chapter.plainText.hashCode() + + chapter.semanticBlocks.hashCode() + + chapter.htmlContent.length + + chapter.htmlContent.hashCode() + + chapter.baseHref.orEmpty().hashCode() + } +} + +@Composable +private fun DesktopEpubPaginationPreparing( + active: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + if (active) "Preparing pages" else "Measuring reader layout", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + @Composable private fun DesktopEpubWebView( html: String, + appearanceScript: String, navigationTarget: ReaderContentNavigationTarget, highlights: List, onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit, onLinkClicked: (DesktopEpubLinkClick) -> Unit, onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + networkAccessEnabled: Boolean, modifier: Modifier = Modifier ) { val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) + val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected) + val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation) val latestOnSelectionAction by rememberUpdatedState(onSelectionAction) val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) val scope = rememberCoroutineScope() - val linkRequestInterceptor = remember(scope) { + val linkRequestInterceptor = remember(scope, networkAccessEnabled) { object : RequestInterceptor { override fun onInterceptUrlRequest( request: WebRequest, navigator: WebViewNavigator ): WebRequestInterceptResult { + if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) { + logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"") + return WebRequestInterceptResult.Reject + } if (!request.isForMainFrame) return WebRequestInterceptResult.Allow val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow logEpubLink( @@ -6946,11 +11180,27 @@ private fun DesktopEpubWebView( navigator: WebViewNavigator?, callback: (String) -> Unit ) { - EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)?.let { highlight -> + val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params) + if (highlight == null) { + logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}") + } else { scope.launch { latestOnHighlightCreated(highlight) } } } } + val highlightSelectionHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerHighlightClicked" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + message.params.readerHighlightClickOrNull()?.let { highlightClick -> + scope.launch { latestOnHighlightSelected(highlightClick.highlightId) } + } + } + } val positionHandler = object : IJsMessageHandler { override fun methodName(): String = "readerPositionChanged" @@ -6978,6 +11228,19 @@ private fun DesktopEpubWebView( } } } + val keyNavigationHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerKeyNavigation" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + message.params.readerKeyNavigationOrNull()?.let { action -> + scope.launch { latestOnKeyboardNavigation(action) } + } + } + } val ttsHighlightLogHandler = object : IJsMessageHandler { override fun methodName(): String = "readerTtsHighlightLog" @@ -6989,6 +11252,39 @@ private fun DesktopEpubWebView( logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}") } } + val selectionDebugLogHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerSelectionDebugLog" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900)) + } + } + val paginationLayoutLogHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerPaginationLayoutLog" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) + } + } + val gapLayoutLogHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerGapLayoutLog" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) + } + } val linkHandler = object : IJsMessageHandler { override fun methodName(): String = "readerLinkClicked" @@ -7011,103 +11307,168 @@ private fun DesktopEpubWebView( } } bridge.register(highlightHandler) + bridge.register(highlightSelectionHandler) bridge.register(positionHandler) bridge.register(selectionActionHandler) + bridge.register(keyNavigationHandler) bridge.register(ttsHighlightLogHandler) + bridge.register(selectionDebugLogHandler) + bridge.register(paginationLayoutLogHandler) + bridge.register(gapLayoutLogHandler) bridge.register(linkHandler) onDispose { bridge.unregister(highlightHandler) + bridge.unregister(highlightSelectionHandler) bridge.unregister(positionHandler) bridge.unregister(selectionActionHandler) + bridge.unregister(keyNavigationHandler) bridge.unregister(ttsHighlightLogHandler) + bridge.unregister(selectionDebugLogHandler) + bridge.unregister(paginationLayoutLogHandler) + bridge.unregister(gapLayoutLogHandler) bridge.unregister(linkHandler) } } - key(html) { - val state = rememberWebViewStateWithHTMLData( - data = html, + val state = remember { + WebViewState( + WebContent.Data( + data = html, + baseUrl = null, + encoding = "utf-8", + mimeType = "text/html", + historyUrl = null + ) + ) + } + + LaunchedEffect(html) { + navigator.loadHtml( + html = html, baseUrl = null, - encoding = "utf-8", mimeType = "text/html", + encoding = "utf-8", historyUrl = null ) + } - Box(modifier = modifier) { - WebView( - state = state, - modifier = Modifier.fillMaxSize(), - captureBackPresses = false, - navigator = navigator, - webViewJsBridge = bridge + Box(modifier = modifier) { + WebView( + state = state, + modifier = Modifier.fillMaxSize(), + captureBackPresses = false, + navigator = navigator, + webViewJsBridge = bridge + ) + + LaunchedEffect(state.loadingState) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript( + """ + (function () { + if (window.readerDesktopKeyNavigationInstalled) return; + window.readerDesktopKeyNavigationInstalled = true; + document.addEventListener('keydown', function (event) { + var target = event.target; + var tag = target && target.tagName ? target.tagName.toLowerCase() : ''; + if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return; + var action = null; + if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search'; + else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch'; + else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next'; + else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous'; + else if (event.key === 'Home') action = 'first'; + else if (event.key === 'End') action = 'last'; + else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen'; + if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return; + event.preventDefault(); + event.stopPropagation(); + window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action })); + }, true); + })(); + """.trimIndent() ) + } - LaunchedEffect( - navigationTarget.autoScroll, - navigationTarget.readingMode, - state.loadingState - ) { - if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect - val autoScroll = navigationTarget.autoScroll.sanitized() - val command = if (autoScroll.enabled) { - "window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});" - } else { - "window.readerAutoScroll && window.readerAutoScroll.stop();" - } - navigator.evaluateJavaScript(command) + LaunchedEffect(isFullscreen, state.loadingState) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};") + } + + LaunchedEffect(html, state.loadingState) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") + } + + LaunchedEffect(appearanceScript, state.loadingState) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript(appearanceScript) + } + + LaunchedEffect( + navigationTarget.autoScroll, + navigationTarget.readingMode, + state.loadingState + ) { + if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val autoScroll = navigationTarget.autoScroll.sanitized() + val command = if (autoScroll.enabled) { + "window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});" + } else { + "window.readerAutoScroll && window.readerAutoScroll.stop();" } + navigator.evaluateJavaScript(command) + } - LaunchedEffect( - navigationTarget.requestId, - navigationTarget.readingMode, - state.loadingState - ) { - if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect - val locator = navigationTarget.locator ?: return@LaunchedEffect - navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") - } + LaunchedEffect( + navigationTarget.requestId, + navigationTarget.readingMode, + state.loadingState + ) { + if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val locator = navigationTarget.locator ?: return@LaunchedEffect + navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") + } - LaunchedEffect( - navigationTarget.ttsRequestId, - navigationTarget.ttsLocator, - navigationTarget.readingMode, - state.loadingState - ) { - if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect - val locator = navigationTarget.ttsLocator - val command = if (locator == null) { - logDesktopTts( - "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" - ) - "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" - } else { - val follow = navigationTarget.readingMode == com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL - logDesktopTts( - "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + - "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + - "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + - "text=\"${locator.textQuote.orEmpty().logPreview()}\"" - ) - "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" - } - navigator.evaluateJavaScript(command) - } - - LaunchedEffect(highlights, navigationTarget.readingMode, state.loadingState) { - if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect - navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights(${EpubAnnotationSerializer.highlightsToJson(highlights)});") - } - - val loadingState = state.loadingState - if (loadingState is LoadingState.Loading) { - LinearProgressIndicator( - progress = { loadingState.progress }, - modifier = Modifier.fillMaxWidth() + LaunchedEffect( + navigationTarget.ttsRequestId, + navigationTarget.ttsLocator, + navigationTarget.readingMode, + state.loadingState + ) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val locator = navigationTarget.ttsLocator + val command = if (locator == null) { + logDesktopTts( + "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" + } else { + val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL + logDesktopTts( + "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + + "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + + "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + + "text=\"${locator.textQuote.orEmpty().logPreview()}\"" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" } + navigator.evaluateJavaScript(command) + } + + LaunchedEffect(highlights, state.loadingState) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights(${EpubAnnotationSerializer.highlightsToJson(highlights)});") + } + + val loadingState = state.loadingState + if (loadingState is LoadingState.Loading) { + LinearProgressIndicator( + progress = { loadingState.progress }, + modifier = Modifier.fillMaxWidth() + ) } } } @@ -7117,6 +11478,10 @@ private data class DesktopReaderPosition( val locator: ReaderLocator? ) +private data class DesktopReaderHighlightClick( + val highlightId: String +) + private data class DesktopEpubLinkClick( val href: String, val chapterIndex: Int?, @@ -7126,6 +11491,15 @@ private data class DesktopEpubLinkClick( val source: String = "bridge" ) +private fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick { + return DesktopEpubLinkClick( + href = href, + chapterIndex = chapterIndex, + text = text, + source = "native" + ) +} + private data class DesktopEpubHandledLink( val href: String, val handledAtMs: Long @@ -7134,16 +11508,69 @@ private data class DesktopEpubHandledLink( private enum class DesktopReaderSelectionAction { DEFINE, SPEAK, - DICTIONARY, - TRANSLATE, SEARCH } +private enum class DesktopReaderKeyNavigation { + NEXT, + PREVIOUS, + FIRST, + LAST, + SEARCH, + NEXT_SEARCH, + EXIT_FULLSCREEN +} + +private fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? { + if (id != AwtKeyEvent.KEY_PRESSED) return null + if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { + return DesktopReaderKeyNavigation.EXIT_FULLSCREEN + } + if (isControlDown && keyCode == AwtKeyEvent.VK_F) { + return DesktopReaderKeyNavigation.SEARCH + } + if (isControlDown && keyCode == AwtKeyEvent.VK_G) { + return DesktopReaderKeyNavigation.NEXT_SEARCH + } + return when (keyCode) { + AwtKeyEvent.VK_RIGHT, + AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT + AwtKeyEvent.VK_LEFT, + AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS + AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST + AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST + else -> null + } +} + private data class DesktopReaderSelectionActionPayload( val action: DesktopReaderSelectionAction, val text: String ) +private fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { + fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val highlightId = obj["id"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: obj["highlightId"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopReaderHighlightClick(highlightId) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? { fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching { val obj = Json.parseToJsonElement(rawJson).jsonObject @@ -7162,8 +11589,6 @@ private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPa ) { "define" -> DesktopReaderSelectionAction.DEFINE "speak" -> DesktopReaderSelectionAction.SPEAK - "dictionary" -> DesktopReaderSelectionAction.DICTIONARY - "translate" -> DesktopReaderSelectionAction.TRANSLATE "web-search", "search" -> DesktopReaderSelectionAction.SEARCH else -> return@runCatching null } @@ -7176,6 +11601,38 @@ private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPa }.getOrNull()?.let { parse(it) } } +private fun String.readerSelectionDebugMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +private fun String.readerPaginationLogMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + private fun String.readerPositionOrNull(): DesktopReaderPosition? { fun parse(rawJson: String): DesktopReaderPosition? = runCatching { val obj = Json.parseToJsonElement(rawJson).jsonObject @@ -7201,6 +11658,32 @@ private fun String.readerPositionOrNull(): DesktopReaderPosition? { }.getOrNull()?.let { parse(it) } } +private fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? { + fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching { + val action = Json.parseToJsonElement(rawJson) + .jsonObject["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?: return@runCatching null + when (action) { + "next" -> DesktopReaderKeyNavigation.NEXT + "previous" -> DesktopReaderKeyNavigation.PREVIOUS + "first" -> DesktopReaderKeyNavigation.FIRST + "last" -> DesktopReaderKeyNavigation.LAST + "search" -> DesktopReaderKeyNavigation.SEARCH + "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH + "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN + else -> null + } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + private fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? { fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching { val obj = Json.parseToJsonElement(rawJson).jsonObject @@ -7321,7 +11804,7 @@ private fun DesktopWebViewRuntimeIndicator( val message = when { state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}" state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup." - state.downloadProgress >= 0f -> "Downloading embedded webview ${state.downloadProgress.toInt()}%" + state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%" else -> "Preparing embedded webview..." } @@ -7351,26 +11834,6 @@ private fun DesktopWebViewRuntimeIndicator( } } -private fun String.highlightQuery(query: String, color: Color): AnnotatedString { - val normalized = query.trim() - if (normalized.length < 2) return AnnotatedString(this) - - return buildAnnotatedString { - append(this@highlightQuery) - var startIndex = 0 - while (startIndex < this@highlightQuery.length) { - val index = this@highlightQuery.indexOf(normalized, startIndex, ignoreCase = true) - if (index < 0) break - addStyle( - style = SpanStyle(background = color), - start = index, - end = index + normalized.length - ) - startIndex = index + normalized.length - } - } -} - @Composable private fun SemanticBlockView( block: SemanticBlock, @@ -7379,7 +11842,7 @@ private fun SemanticBlockView( searchHighlight: Color, fallbackTextAlign: TextAlign, fallbackFontFamily: FontFamily, - settings: com.aryan.reader.shared.reader.ReaderSettings + settings: ReaderSettings ) { val modifier = Modifier .fillMaxWidth() @@ -7507,7 +11970,7 @@ private fun SemanticTextView( searchHighlight: Color, fallbackTextAlign: TextAlign, fallbackFontFamily: FontFamily, - settings: com.aryan.reader.shared.reader.ReaderSettings + settings: ReaderSettings ) { Text( text = block.toAnnotatedString(searchQuery, searchHighlight), @@ -7574,126 +12037,31 @@ private fun String.toComposeFontFamily(): FontFamily { } } -private fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { - return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull() +private fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { + customFontPath?.takeIf { it.isNotBlank() }?.let { path -> + runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it } + } + return fontFamily.toComposeFontFamily() } -@Composable -private fun ReaderSidebar( - session: ReaderSessionState, - onSearchChange: (String) -> Unit, - onPreviousSearchResult: () -> Unit, - onNextSearchResult: () -> Unit, - onGoToChapter: (Int) -> Unit, - onGoToPage: (Int) -> Unit -) { - Surface( - modifier = Modifier - .width(280.dp) - .fillMaxHeight(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) - ) { - LazyColumn( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - items(session.reader.book.chapters.indices.toList()) { index -> - val chapter = session.reader.book.chapters[index] - val selected = session.reader.currentPage?.chapterIndex == index - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToChapter(index) } - ) { - Text( - chapter.title, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - if (session.bookmarks.isEmpty()) { - item { - Text("No bookmarks yet", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - items(session.bookmarks, key = { it.id }) { bookmark -> - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToPage(bookmark.pageIndex) } - ) { - Column(modifier = Modifier.padding(8.dp)) { - Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - } - } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = session.searchQuery, - onValueChange = onSearchChange, - label = { Text("Find in book") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - if (session.searchQuery.isNotBlank() && session.searchResults.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "${session.activeSearchResultIndex + 1} of ${session.searchResults.size}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onPreviousSearchResult) { - Text("Prev") - } - TextButton(onClick = onNextSearchResult) { - Text("Next") - } - } - } - } - if (session.searchQuery.isNotBlank() && session.searchResults.isEmpty()) { - item { - Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - items(session.searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToPage(result.pageIndex) } - ) { - Column(modifier = Modifier.padding(8.dp)) { - Text("Page ${result.pageIndex + 1} - ${result.chapterTitle}", fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) - } - } - } - } - } +private fun List.samePageLayoutAs(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> + val left = this[index] + val right = other[index] + left.pageIndex == right.pageIndex && + left.chapterIndex == right.chapterIndex && + left.startOffset == right.startOffset && + left.endOffset == right.endOffset && + left.text.length == right.text.length && + left.semanticBlocks.size == right.semanticBlocks.size } } +private fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { + return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull() +} + @Composable private fun ScreenScaffold( title: String, @@ -7785,27 +12153,30 @@ private fun SharedReaderScreenState.withBanner(message: String, isError: Boolean private val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) private val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) -private val DesktopBookFileTypes = SharedFileCapabilities.all - .filter { capability -> - capability.type in DesktopReadableFileTypes && capability.type != FileType.PDF - } - .mapTo(mutableSetOf()) { it.type } +private val DesktopBookFileTypes = DesktopReadableFileTypes private val DesktopBookFileDialogPattern = SharedFileCapabilities.all .filter { it.type in DesktopBookFileTypes } .flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } } .joinToString(";") +internal fun desktopBookFileTypesForDialog(): Set = DesktopBookFileTypes + private const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme" private const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues" private const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112" private const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader" private const val EpistemeSupportEmail = "epistemereader@gmail.com" -private const val EpistemeFeedbackSubject = "Feedback: Episteme Reader" + +private fun desktopFeedbackSubject(profile: DesktopBuildProfile): String { + return "Feedback: ${profile.appName}" +} private fun desktopAppVersionName(): String { - return EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion - ?.let { "Version $it" } - ?: "Desktop development build" + val version = System.getProperty(DesktopVersionProperty) + ?.takeIf { it.isNotBlank() } + ?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion + ?.takeIf { it.isNotBlank() } + return version?.let { "Version $it" } ?: "Version unavailable" } private object EpistemeDesktopAppVersion @@ -7814,25 +12185,6 @@ private fun ImportedBookFile.desktopFileType(): FileType { return SharedFileCapabilities.fileTypeForName(name) } -private fun mergeSyncedFolders( - existing: List, - folderRoots: List, - nowMillis: Long -): List { - if (folderRoots.isEmpty()) return existing - val byRoot = existing.associateBy { it.uriString }.toMutableMap() - folderRoots.forEach { root -> - val rootFile = File(root) - byRoot[root] = SyncedFolder( - uriString = root, - name = rootFile.name.takeIf { it.isNotBlank() } ?: root, - lastScanTime = nowMillis, - allowedFileTypes = DesktopSyncableFileTypes - ) - } - return byRoot.values.sortedBy { it.name.lowercase() } -} - private object DesktopFolderPathResolver : SharedFolderPathResolver { override fun relativeFolderSegments(item: BookItem): List { val sourceFolder = item.sourceFolder ?: return emptyList() @@ -7891,63 +12243,68 @@ private fun DesktopExternalLinkDialog( val clipboardManager = LocalClipboardManager.current LaunchedEffect(url) { logExternalLink("dialog_show url=\"${url.logPreview()}\"") - when (withContext(Dispatchers.IO) { showNativeExternalLinkDialog(url) }) { - DesktopExternalLinkAction.COPY -> { - logExternalLink("dialog_copy url=\"${url.logPreview()}\"") - clipboardManager.setText(AnnotatedString(url)) - } - DesktopExternalLinkAction.OPEN -> { - logExternalLink("dialog_open url=\"${url.logPreview()}\"") - openExternalUrl(url) - } - DesktopExternalLinkAction.DISMISS -> { - logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") - } - } + } + fun dismiss() { + logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") onDismiss() } -} - -private enum class DesktopExternalLinkAction { - COPY, - OPEN, - DISMISS -} - -private fun showNativeExternalLinkDialog(url: String): DesktopExternalLinkAction { - val result = AtomicReference(DesktopExternalLinkAction.DISMISS) - val options = arrayOf("Copy", "Open", "Cancel") - val showDialog = { - val pane = JOptionPane( - "You clicked on an external link:\n\n$url\n\nWhat would you like to do?", - JOptionPane.QUESTION_MESSAGE, - JOptionPane.DEFAULT_OPTION, - null, - options, - options[1] + DesktopReaderBottomSheet( + title = "External link", + onDismiss = ::dismiss + ) { + Text( + "You clicked an external link.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) - val dialog = pane.createDialog(null as java.awt.Component?, "External Link") - dialog.isModal = true - dialog.isAlwaysOnTop = true - dialog.isVisible = true - result.set( - when (pane.value) { - options[0] -> DesktopExternalLinkAction.COPY - options[1] -> DesktopExternalLinkAction.OPEN - else -> DesktopExternalLinkAction.DISMISS + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Text( + url, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = ::dismiss) { + Text("Cancel") } - ) - dialog.dispose() + TextButton( + onClick = { + logExternalLink("dialog_copy url=\"${url.logPreview()}\"") + clipboardManager.setText(AnnotatedString(url)) + onDismiss() + } + ) { + Text("Copy") + } + TextButton( + onClick = { + logExternalLink("dialog_open url=\"${url.logPreview()}\"") + openExternalUrl(url) + onDismiss() + } + ) { + Text("Open") + } + } } - if (SwingUtilities.isEventDispatchThread()) { - showDialog() - } else { - SwingUtilities.invokeAndWait { showDialog() } - } - return result.get() } private fun openExternalUrl(url: String) { + if (!currentDesktopBuildProfile().featurePolicy.projectLinks) { + logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"") + return + } val normalizedUrl = url.normalizedExternalUrl() runCatching { if (Desktop.isDesktopSupported()) { @@ -7975,29 +12332,58 @@ private fun String.normalizedExternalUrl(): String { } } +private fun String.isRemoteNetworkUrl(): Boolean { + val trimmed = trim() + return trimmed.startsWith("http://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) || + trimmed.startsWith("ws://", ignoreCase = true) || + trimmed.startsWith("wss://", ignoreCase = true) +} + private fun String.urlEncode(): String { return URLEncoder.encode(this, Charsets.UTF_8.name()) } - -private const val PdfSelectionLogTag = "EpistemePdfSelection" +private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" private const val PdfLinkLogTag = "EpistemePdfLink" private const val EpubLinkLogTag = "EpistemeEpubLink" +private const val EpubPaginationLogTag = "EpistemeEpubPagination" +private const val ReaderGapLogTag = "EpistemeReaderGap" +private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" private const val ExternalLinkLogTag = "EpistemeExternalLink" private fun logPdfSelection(message: String) { - println("$PdfSelectionLogTag $message") +} + +private fun logPdfZoomPerf(message: String) { + logDesktopDiagnostic(PdfZoomPerfLogTag) { message } +} + +private inline fun logPdfZoomPerf(message: () -> String) { + logDesktopDiagnostic(PdfZoomPerfLogTag, message) } private fun logPdfLink(message: String) { - println("$PdfLinkLogTag $message") + logDesktopDiagnostic(PdfLinkLogTag) { message } } private fun logEpubLink(message: String) { - println("$EpubLinkLogTag $message") + logDesktopDiagnostic(EpubLinkLogTag) { message } +} + +private fun logEpubPagination(message: String) { + logDesktopDiagnostic(EpubPaginationLogTag) { message } +} + +private fun logReaderGap(message: String) { + logDesktopDiagnostic(ReaderGapLogTag) { message } +} + +private fun logEpubSelectionDebug(message: String) { + logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } } private fun logExternalLink(message: String) { - println("$ExternalLinkLogTag $message") + logDesktopDiagnostic(ExternalLinkLogTag) { message } } private fun DesktopPdfLinkTarget.formatLogTarget(): String { @@ -8015,6 +12401,11 @@ private fun Float.formatLogFloat(): String { return String.format("%.3f", this) } +private fun Offset?.formatLogOffset(): String { + if (this == null) return "none" + return "${x.formatLogFloat()},${y.formatLogFloat()}" +} + private fun IntSize.formatLogSize(): String { return "${width}x${height}" } diff --git a/desktopApp/src/desktopMain/resources/episteme.ico b/desktopApp/src/desktopMain/resources/episteme.ico new file mode 100644 index 0000000000000000000000000000000000000000..a78642963de5468491cb60f36b2c7a6e059d303f GIT binary patch literal 15057 zcmeHtWmFtb*Jsb*9s)swLxA8eL5AS&?jg7Z3l0M$xCRIiBxrDVCpf{~-QC@G@_(M) zvmf5G@7d?up0hJ&rn|cCt-f{Zcdfbs00bZbFJ1t!BRNnF27u2101y!TtE~wK01U7n zIr+cZ`0xPWj|c#)tp94OApk%UDgZzr|7r)p`VO%G01EwA8x0)*obg}-!aje;SAmV| zO9=oWN(zz~s6;SM07F_zOa*p?NdUY+hVcVr=g-El1IS55@;y*8^m-T8fHxPB7Xg6s zD6|J-*gXN1Pg2@W0D#%?_Xp~+&-(}fU>RvK5j9@L_}E8l6=i~+$AC|`lPC5*Ol!+Ybe5p70p-JC-i)tJ|R zQJQZ=4(Z~YvVt|0Ul5@jI1)K99*Foppn8<(XYZwk8?j<{wOtnS6206dk=V!!4#L(- z0wbp;o^QTnS0QbkK9fNp`(BYa>?>kdA6&wwNiqhn)DTV}BX%nNXI-o_D#9uryf%(i zZd-+a@2w#f&+hT2U1=7>7c3P)eR(ncI#TlC5}gQCuY#TXA>$dYH%HA5yOL~NYCxd- za}5>}%Z!rFqr&}}Vw-tAR1}1C)cZg`Q_-ha}n+Y$$ znbBbkl`zVv`WZB2+dgQnc*=fB+NU|}z`OJ1kMYer$Tqj=A<I@5%$-Nm|p&7I=wAK~{W zF}@rvi+_3>(l$Zj93=K}6Ra^k^=bFFAmc03BF#cxcIFY`N=zdGGoAfId=9tf==S8V zd~Y8k2oxlG%Cw4dTF=grQu=hO^5q3*);BZov<^0qMy&`7!aC<{J9t+r*PI!@l%(Sn z<9VNRj2WNJ?}=vGzKFPW%UgU+lDCKfCoWKJ{IafUsH6W&&8tZPy*vEhKf)N~d)~m{ z4gk{P3SuSijr{)$(8Ayh5c~t$6<5IVQ#RXZRJeY0+J_r7BJT_g#tk4?Nf`xgV ziL_p#9XQ`w?{+kq8^x>gos0z9{)?oc32jExjnH<=j}>y%!GDmj3B7m?7%m@JpNpR2#6KEtPTx`d-wu3nRcSrG1Y z-Q6_+`94dGxB_OtXVqAWw}%)qYpO04hck;}zQx0~Hcf40&I69}h7o45Jg>jM`3 z*$o?nzo@r^Fcwef88x}SnRLmvMmgm3KKU?)ck0?N@=|$FNC7w!nkEu)%x_%d?BL0* zz0`h@`IVyi>J+0bGicsz_84D9ZF)5HrdUNR7T2$#9))yS(OCIY5h9q^cOF{#%aJ?k z_3`2Qg7|NRIb4WsViA3NpB0y*-Cm4pA&&Er22HYRoU`qLPX}M_xWcINpbCS`jEOA7 zLS%P1CNp!KF#Z0f!fS}AJUDZ*)gYMysAp+3!!o$zYM1r37uJ0OD9!7a zhu42<{WoSs4(liX$E=(@JTCqdvw|!7YgSs4hY7QaIDgGbQ7*i@voo~|!PFt7dnZeI zJ)p51GW#wEP5%X>g`Sd{*o#lH`X44bE#TuV{2S>q>2MjuV(`Q)Us+PY%*vT@Jd|l_ zFMyUW>GRRT`Quia=WsI)wmq)SOEkxZ_46-f{FZMBUKL}bxW8X+r)1i~&uibyPBOxM zjV&h1^YRSVZ%y<{@c9koLmIR`kWnmuGgu8_TX|2dldys>P>quu*yEK<2oXoHqEq-T z>eRqbH!NV#?Xb01=_LjSPUkUf(hu0GB_SxDZ1wQt%>p}g+mNjqf=5^j2I61mU01>- z5Ff3lZRV)KG1n6F{1g_KUpJW$vVKu&h=^F{?KEr9X)*5g1r9ut#@8)ETGiZ6aH~^9 zPyekKj_kE}qIqvOu1KJiI_fWm*c+?r`8s`#ax%6b%3vktK2lv>HtlHR{+JIT0wep* zMwZ4UiPk^doNbS5obIR2yq`huk;-PV+IohEP5`-=HIzALKnn(DyPjcCM9}rhXD6JN z27Rp-(PKCmx8w3g7EQ25?j14`2IN`ufPRHjZdiINNk}yr(S1^c6r^)J!LpRMLaCvy z8GwehSMKpM1nq|!uLV?l4!v`aolP~FSxEE)XA`M|;~hzkbl1%V20?NI=6IYml|}~? zB$WuC0}@E9lkYT%8OW)>G1i;&h|_NcE2><4t7oNzUfc1qS%=jo9UKPPVXssd2;;%Eg5>BMPGa5tG1yK5<47d6w{3@*@~Jja-r8Pt_fCf z&wDB)U4u4r0cT2`4`Z2|kB!D6*=Dz5@Cc|zx&#a z6RM28qMylS(gWimVS68{!s?x9qZ>IH@T?=zAz5{6-`HG_)(#x1~-PJs0vx1b5W4f;>q0{re@ zw_qW9sB^EVs`+}`aQ0zxU{}m*C7ZxUYYQ*IwEHl3&?YHT(r;|3Mrm&8!>bqLexeZ4 zP&oVny$yO6w)vM^f$gtFY3+?!;U~1q1ms%!uhFi@JntGJa`a<;zA4Tvc^+MR32*xh zUwaKpXW3YcZ4C|3@Ycfi*3hckR?hA&=pi!}zu+5c z9~6iG5{eDV_+m&!}qKngAZuJdX?tO7oLwYl5bqRx|;8*N)>pMqa@cdVA>&&wC7N#MI<<_d4b z*FRJGzc$bAGCz7|F@gvRU+EWrko1+8hbUVMmgkg8uEB>L{k~UnTj!5+sgcr}n?z19-P zxNr*|T`n@rwtnu<3bMuSJ;u@)ZO706?ubGD%hEW}(bbH}JS_?GluS9)1)Msp^aNaH3O6A)MTEl# zhuX60d!jY;QQ3T^L`$j3g(T7LoJm!31qbh5*$d@nkJN#62HA5--i)TjJI1 zcLwSmFe{bu?83x7gMfBf7&mr*Vc{oxFWiCU=@Dj(E;lsck4gyXB+f(S6)|Rlt5a=) z9=dhHZHiOh+tQJPYb}g}FjeiUJEaYG2;Gs(-@LLl*G>ipPy)4CWVk0~byd_3pZ0bI zE)>J1OUm0K;u^my87aZ9oO21gO1~Dph{Wh9VWgS);S2ROWUQz~`P_r%#xpEM7*-v& z;0&rVN@6$K`AHvQg1iv1N*Ppmy~<%06@*wv)>HAdIs!()>lkzvVJCSr+~@Fz6II}{ zS0UH^Ogst04ofh)>fQ2^lu%hsjL3_0|Lftzww8@(-mjnVpg5QEwv;)8ckf!I=2j^n z5CkJk-+*@_S^E(LLhnP^_=Fi8P$`hxq(k33FbnAF872E|>J;~YVwuu2_X91;9-|sJ z2uiY-f``{aLo@s`wI76pz`Bz?`Q)!}?y)_S%kIu-Uf<5iB!yr0vw)VXxPm1dy1W{U za!1d)|6HmN6^@(GnA1d31e*WSZ~rRpRC@g>o(7oo{aD$)R>2u{-`@T$UMO2Ku?;@&}4OzhxWga~WgFd2=WAo_*_vtW{E%o#KUIiQA2@51s&G8y(sPbA^F{Jx`SQi^2)LL21 zOfZ_fcV1!vW#a7X{?^J0ec!qNf^* zuM1FUzYPoU+>oj195qJD#*Y0rJ{1D%hy3GH3oIR${u7^y`1-F;bu&0jzEymsiQ98) zIF~KVDUZ6=QXZ#~JoYq}Z8=8s+sYYy7amdHeNcJfu_AkA{AtKU=me&usMmT%bdVciS!}%IU5z4^$rDDsF-3-<~*@fR!IFDTfIMERyoIRdn-<%*q_ho^Rtuy9VFVx$^R&19BE#$hMXB z=EFC#-?)VY88zkk%=R>#{$ZgpT6g<5@7h8 zUH(I|PnO3^LpQ!e)HuHzu#$SAof3j-Rj94$8^79%8M7wQzt+4`4EHH)syrlW_#KWLE}X+1wR{6r10&j`^I}_USWyE`E~>5PZ_~A^f08@ecs3vKXzB00E3$x8Gz< zl^;#Dg_>OeWZPD`$n1Pt4uAH;2qeIGvh9i}hMcHh*VJjeaTK6t zC=)}=e7`ibWaGsH*i;8rl)4{}ii#v%nZd1)7Mn02ZuuRL$L3wxF+A_c_BT5ld{?xG zhd!?6=^3ZiATZFKKNR0G#w3E;tegNoVy{`R7}t8M(HTGxE}3r0*Ps9hCW%5K?IXu| zH)Np4d)3`KO>XSZzMA(mV-J(AxMZE%J9}OpyX$sBm{MqWc7CLjK=zN3|K{U%4gt^* z|3fRaw&rOmL06kvVuVWP?&vg%ZQqTxja$HxY14)Yf(_&N=_aM zgbM^4c_pMtDQ=nF_TUn(p?r*DtvzW8lV$^YCwCNE<9=puvlyIQGO&ATJFD{UW0Q2Z z==6>ZDd*+FuRwG#qrWb z2z2l8qKt|UnlY4K&zJ@qv0R{Xt@Zl>pnXA)j;k-9#>o0JQubtO`rl1^MaU3Y&HABv zU^xb8B~5H0EE7O;UcSuIdUNrMZ04u73%_`tIpzcIp- z8~^XCb<4t*d5&&gFcE|Fx$TteGt!svag3X^-Gcun3_)T2(0{^Ehmdjqe-eh!(*GTX zQg>C=W(m6=b-xvOC1J(xghz5Hz(+`%>3vJgr&f=ZqrqW_MDq{jt{TA@%=8accED#+ zcEANW5MR^yqaB5=`3D3LC(C9b2e;&Jb25EuVEKIATx;Lq{<9#Lj_BxceEZ=p?NJae zQ?F*8iXDeIYs|*xYdCw<=fM^Za{j?B()f5eDvrZ{U7P)Z1VwCmk!ohb@KJfIWL6#> znrQ~$^1!9n5dvL<0G%Bo#DN0{O$I3~phMUG&t0{qzyrSv9)^otZq#fu^$maPD9xuR|G^cX^>~V@@TV-(j%V8*)sp9suA&@)Nluu-rz4~ z;FHl5OHTfN5#mU)Y)=fAi>*W5J$uJis|?jzJEezTTxyx{{*P&vVk!qE9(WwohSbC3yY-7}Vri6OTe)?*FF8K5`P^NmNpVTc zUmj^iwFmvqNrAs!*WizPRygXP$Wh5Ff}c$x@X45MPr1%2mwx?Y)1toSe+{*oOm)v=iV%2B1d zE*RjlniQ*cS*GEWOxmEO%G8ZSX=~Hw46s7Rl1F%7$D~P-&Aka*dheRGeHd^p%9v7NTYD+KCJX`UT1GPxhi-ze=+iv?3fjJ`eMIchmg<7X!>jTvbRX_ z+xpJK;yjE|ZCpu`Y8*%WTFz%!2|ap>Oqje?9*Yei`T!Jh!L86P{{8_PVTBu^L4=5+ zL#xezhwOJ+8~~9S(9j831cS<^(4jw_k*pCR9q5n*0sw;znEC-ai9IqJ1S}FkJ1^nV zDG=LqH~^n9k|II?g9Fh20C08!TGohdiVXo=aOuW~>9Xk1Kmx#e(H^MN(G^XiSysI* z-aq^4)d@J)55p-vKMFZ64nBMP=2d$YtR8#T^@Ba8s z9F)$A?POGEU8AKS^tMBx5c816RW{6v9&TjN`z16hK4F)ve)dD*q`AWL{b3mk83Koo z@{bu{OdlpX6)BWLy4<_d%LUva9HtSMpGC+r@ z9k?mr0gNX0v)B(Qw<4FoqW8cxVNx2TwNL8e)-w-L3mIDOW{ARv#-qF!Ie46+QWvt7 zV3+Iut~TUCGKUQiybuD6nh{v$ckV$dU;IQ~AeS4C99gSr#}K^vr3kcf5C8AB8W29h zVxdRL{1P$a#Qg-P)l=uWWm?Ri5FmLH;44~Xmlg`35hvvOi<@;gZ}VSQI;VUk9luLb+0A6=`uk zdA&w4$cvq_8bUJw%-WIzcc?1fIsPqFbz*Lb**84;;_6VBU&UQpOr9)`U_EK{;{3_% z2WY2@4t-+!W-1pSe)M) z7mxWk6Q^4dUfN3SA#Vc?x;5d@p*we7N~5zQL7tBwz%%MkS9c6bp;KPw-FGj`wuZ+Y z5QbtZ4P$E*DFYTTwVQS6dt|rsH46daniUhm{a(VsFK;~H)fI1KzXF@U&p0F?%8Ikh zikNsK8=(jSK!X~Um^w#xZZRDasWAso)p*U_+xNOY6uoaL{b#^KW0dLh1eSlOK__#b z{&%&7j$#6EMQF%k=J<{bu*W)IL5Fq;fu*+*(Vn)Cq%l1U%LF~`-vhnSApRZIm z(Ya^d7m1)U0I)}gle^j=c?XFArG*7IeFV^Jw%z7GvTYC*_=AA$SDB-`Gc_?sToZk@ zd+Msw+o6+PXM&en?f@$ilzzHU_>qp5$*36%dbB7~^vr-p9vQ0MUx`91C<|(8}Sc;9`pu1Crucj&cG{#&rum{+6r?z zJ^~v(%|!{t#hgAdrskww8`-DP^u#_e! zJmACT?e7*!fjh8nen)y;5$z+V)o01w{tFCc?%!zAyFJ%>V{Q757zA86~H@J@KUF>D&bn!)aHs4~i*Fpgqk z_99A$uPj@=;ru3JMkjJqhOby+))^RSf7<_6e^F7a{^a!?dA@Fxe8K}Yom5g&cDOAX zyZz#L*{PDBC+OzaI9)O(#97Bb&oy=ad~?_$`~= z8vU$&XR=`%dEc zn5*ljIFsF)e1sSC1AHIES#++=>~Ar<(V?I#36w4yk}Y8+b+?G+Q~s+b(T`XVd?c0%y~(1|rX>syzCrf30i32KSH@p7Dx zQUh+ca1JDS_!C%rD3@P7#`nL$N0s5Xh0z=S;c&oar*!xho=nQ}lPQtD@(=f)_E3@v zw`FGfS;YnLo{4f7rn+!mu7mce41AvOM3zA<^`~|XfF(yE-uN_z{VkKuhpD*UD^+2s z#l?4HlZ#B~*GT`|B}~6kY?5jZ=dxg$i%`Ir9vUO9%+Agx^Q%OMn%1XeY96m6z|TX> zyP!cn)FXSSZw2`s2wgFe&VKVvKaV7)jG}$iQX~OsWLg@jw(CW7Q+zVz@EbX=?4yw9 zFY&;L_=Cj}I-l=j!O~{}W8A#usI9HMyQz8LG^z|6AnKq!cih+x)M;*JZD(cXndpPH z>HPo{G>}o?bMu2-eo1qFEsg{M(jM_;WIVW8KH6{99n=)PP%Gek^C4$Rhybs1G)%ZJ z@xsz-7`nvWd%W%rN=M_ZbPiZzCcqPax{`m5ivCg&ZRK{O@61f0LeJi6WFEdFv(7zN zdTB^r`B223YNO9h2M{%(x;Y$Hih;e;QH|6b5ly?U4Qfif%bBAciV#AFp99Uo&3#?c zka~900ETH4zq_8PY#NvcE#VR1A zeKUxvfOr>TjsP{u`s!J;e+V^pz&&0p`CsGlzH6!bkh_HV5Q!v+mHiIz5Ey~mjW^gm z+C{l>#h|IHfnFOF<7IDOE_g?Qa$LrV#gB z6h`OMQ>X7(r!uwB>!fN8ASQVEPQ;)uIDYGj`*G|M6}8Q`2oGW{*~qWMKZ8O2yAF|d z?S2oy$&)&%wDR&wwiHe${_#bz=cl{6lCYJ&Po0}{4FHmlsebNMQ@aczX1U)dK zLD?st4MG`b4Vhi%5nTh6fNWtp01nLSR4w6h`cpRan>o&#KY2Y(*QJC>oZ5jsigCku z19AH13Uux_IJm$r7HXRaNYA!NbNzvhkZn)*IcLIeIl9h|0__6#O)1@X9AV9U`(^uo zjf(^i4UAwGAi5o?L!PXjUbaqeisM%g>=G{^=64z=s$Wpb^^Ngc3TM}{o@YiX|DO-T}1g1{38On7#M;tXyqOwkjyIWvUoX- z7`7ezrayWHMTY&|@aqHvcV{0cnEX)$1g{Mw?Ab|gZIznJ-EndSL@_r$H7Egn;ZiBt z*-i&4llCPc{~{!Z`bH75^kW`r98b>7QKDTRDPa)uw1gyMR1lKFl%1;>8y;bu-B4ry z+?L;;KnI*@(Ln+D6XyeXZMAaMmgY|eLTSnOpS~MB^X2}ID7nJg_|^O4_^L3cgKd|s z1Ro-)nP4F9({%>tNe>8T0N@uui^FnKQbEH#)yu4j+M*|pH-6z|YbOe%C2SHBx%K;Z zUMob)f21`Sh_*`&W16=W`iD4ZbD>ksj4$l2ZnuS@8Q z?c^>JlU!|MAc*8N%l{lL; z)Z!swYL;Us%Xco(La_OhZ>;38{BhHI5OW##JyEZrF4d*bjUtQLn@b*>nJdy9OwY<` z*0sxZlRhzqLWl7A5#KTfd%cTQdHJ3611a<;p2B08hm_37$`ER-<`PsvJmKl2E+67HjT=oME9wK`N z=jAJ|dPQ!JzgBLF$Gad?mNiQLRfPzGzL#??o3Un-YmlC;%}Cgy8~OdG-M-_^^Gq+o zBwlS(oH1W4-E^2N=}>!b=VD11ya{z%m2uqcqUNu4OfPHiKH`1^oNoCnflI6p&n7w_ejnHSU zr?`Xy54f5lJQzIM(tlGgTWg4+v#gM!R?IBl&Sm`IslC0Jrf_ajTxxR zxo9185m6Wq-~Px42gBG3kJ}UZ_iN^brV@P188Jos(t)LNz5|-SY(9Rcr$|l}n<|U) z&Ac7#TM726%_IlJBmI=G|IQB-Kn!Z!Z7S=;AJ8gmlshy-?@5D!7KD!M{PkHUnm%Fg zj+D<%cffN72bl%suaw9BkLOyX?_3W<4Zci?m0rjU)woz~Ql->jsls}3MBZZxe5zb~ zAZ)%V3cD&%NUF%xBH@_o?<<5cLt0moF{5^w7xaJOr&jxAbsrC>mHZKBVPSa>6ZO%b z(AR6-WvR6kKXuX}{jBIM*=w({{sHU1w>A0guvF{#ZL?=z^YTNbP?@cp*)Ad%Td1g# z$E-UF3EB!Qce~0TsG`GR`Gs!?`R6f1OkzF@i;!2 z%=krc_rZ*9lF2@W1M&n%{nC9jix!CrWD5PN8#QM#1Joeal`2p4Pf);=l3ABjvhFB-`De{!=vbc&Hot-_-CRx2#dL~)G zgbhq*Aa$!E7>V|7F(sT9r7g%_3rhP`=Tc2x>KuR`k$Tcw-ADpci;@xgJ9qJNXJNN= zd{rqEa`1_ty;kr_Ry|+E;{@Adad`J?SiBYAh;^(VOd;t6FF8c;jutcUm3uqLKtQ~Y zfbq8X0BKQ`Q2_H5ERU6_dAwr)$PzzwjDK-hyfr1x zX}K+VnuiaG{#xy*)R!rd{v9;f~dcr1d( z!7z9Gw+hiNYgA()m88@H16tgj)T=WCT74|@8#}7O*b&7{XWii|56lW8FeMkvH%$1& z`bLErp|%BiKrA1`Zih=NK8|B8OZPl7KbnZXWFk3y>+&OE4hO-g9$-xh>=3?Z7~bnz zm-_M^FjCVo+a%9CbRFO@0kFy9h8;iTYH3Y)593Jf+JDv zubzJ>+r~u*xA-s5PgA24B zNiO8A|LT#Q?ORD@fRKt;$v>Vipu*7Ao6INO{nDP73ED!{?HwX8&Rz6gewBP<_@*}57j0?4-#5(;AD@3`t-{RL?VM2=<^JqH+B>>&jnlFA*bCwNb^&JUQZ zCs(N-bzmaF7Q<_M-{ISk*cTaK1|it~(U<8*);AYk?9|2U%}NB0NtG-g!GGy%1#7&! z@X{Iw+XiH$%kg<_)$JTs_wj#UdObR}^lMACX(H_}Bo|p>(Qy~HvhcFN&X(|bze(fi zf>2ZGxI=TkvO&L$(#x^Z;o`quaCyMZ!7nBkKoSM&e8MkjnVI6oH3v4pkON*9xir~O z6?PbO|58GAP`x@vRE)^MZPq8n&jupDZ1_HFd7x8s-nzKi60^cQI8e=nXT7m$p5jRv z&4Ne;=&=mln_PlL6B^968}B_9d`FnDW%1nh=>8!_*aESB{>=Y?4Kz&c9GQIgK0Bl2 zv!uJ6UI(u~Yv%TNT0LO>or|BOgEu>irp~)md31gyP6&8+i4J)3ru^9Ky-XcRh+Ny_SD zNUKk+Ni`7NVf*hAj3{aP_YFrhp~c3mq@5>imN4k1;Xe8$J6>acV`)#<9P!)r=;?;% zHY-q-tBcISw5+;guylW@fc(VShfB|u@OvYgm3IWr^%NJnX5Oc~Afxv$8*zkF6KHd2 zBZ;)rE@-!o=I%aW`A?rz0eHt~Lq}W!GVS~1{71_PETL#0KYmztnEnm5D~hFW_hkYm z7o&dWQCFomsqLW)ADWZ%#(l`VRY zhh$=jYl$ywF{M`!Jp>s<0=p&XP&!NX-^TN#_=?RM!J$A{AKH^pn;Dy-unPFp>aKgz z!&{3?%Kqzm^If7xdOAfGKCAh*#$&PQ%fyL}ZJ!hM?*~GjV>?;R4;UX+x|85aeeU6g zQGxZ&YczKK+SecXKizj0(W6+1qJA@L`TmLpmH>|~3+*VwatrS;?kdjx=Z(AG*@mG? z6JN5lS3q|dORi*p6eg!iX;+$4?nJmV`EZ`+8zB`zC8~+sT@SJ$BR7Uv@ zyS7R&Fw?A2l`B_Dyq}d-C@aMBCjq;4qlFlK1|M2-SYXd^QClnYLnnC?mRON);a#w& z;QUm|$Y1b>+E!d6ikIX?bL-R%W^8<6l z53uQ$tcO$3?4A^1yF5&PJlwy)8B+l(vJQ`@3$OVu#@(Bliz8Gune%5Kj9zzHF+PlWHhtFKb^Z?ZUt=f{|IY+k9z1i)PO8gJpO0K!ahL2u- z3+aMY;6`-imgh#d57@J{Nk01c%mfD7Wu_GKW4PMwkEVKyy$rR`KV@6HzCS2f^c`t^yijU zDJ`@%Br&MAjhBhB08-`pGg2~dM_5w|9+L@&$>h(wRs_`V$ZS;*Qonbk`d?w&F8SO7 ztDh9f&FPu~Ux7z{M;z-CRfZUOp9^E6*O!s4hh=(I8+S7}tSiJ0yq%{r88d13X?o@@ z!Ua%ZFU7O47|#W&Tj4DhkIu|xcHj9Xr{)B*1q}XNaiIfVI*-h^$#XF?7!>2Cv~HzQ z&0jJN!YTsLt%Y6Ww|ER3Z$*DFIR%`wL=dd#rqQORpY=Yp!PB7vm9@lKRJ#|=JiQ?rTGYc0h0%|y$vU#?366PJ)SA`%YAN2ilzR$ z5jv|oRWPx_>skieK#MzINuarRWzc3^XcYY$5mUU}if)tn`d}H0ST!z6J-KS!@j#1a z)B0_(!wEz8y G!v6sBn7|MK literal 0 HcmV?d00001 diff --git a/desktopApp/src/desktopMain/resources/episteme_icon.png b/desktopApp/src/desktopMain/resources/episteme_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2063689d6e4f026d569c850d8079c54310c46f9b GIT binary patch literal 12890 zcmeHt`9IX{_y3EMqM}lgeJ5L+BiE=1@{`0Ujri$uTUj~05M{VIr-uI;fRa5ciA-eQ%mLt6`5 z3ANpuGlLnX`}uz?52uAU>+Jym{*3TGCN}_Bt}B2`8^wZh=tuqg_`h2Zme-ube_(d5 zt#}N=XB!+_{H-Y``pulps%vWM-Zmpwqxh2(kX{YsSY$;5`96|zQ%yT&n_!+9MY9O) zrmy%A5Wpa!(>kXwnN7Z{RqE~UmFnVB@htDP3H)PX7do-z9+J{PPDH9=$f-!LTvhG% z7G-S$(bqzc@Afq#4h4grH2*ZU1Md=j36=QaH_SmvW zI0f!*BtHrZ<9z)mr=^k8ECISrIa95q!pXuZDzHyp2Hil;LxMxDy-p6o`&#S4IOY_9 zGp(U-r!~W~zUul~-I$##EH5|5iS04)iAAb5l1bft@`de9r6POuFXRIUSEnATxlB#@ z`xMlj&2n&9p5v2j(>5dIL>lAr_zmKe zD_XGRrKc)$Ddf7amMuXAz`<_@LG>?rU!N|W>Ov#J8jfQ+o+#!*5`Egpv?RQ|jq( zh(wk+@kP7`zt3uM_w<>&U~o^o;NN?d=3(wx z8*UIQQ^`e9{{v?MNLm7%T7itGHP2`GB#pv8iFC~KK7`3f@GAhfzYXh2k{AsIz`8%@ z_S>BN?}rRK3o&V&`rFrRi5@Q@F{wpPY7tK_0Gx-a*Bw?iK4j&s-- z09#!ogNwEK_40uq&XU1$f32--)RW5n1r#$JeA>!dI`PhQ=92zK6z3QUeEMtiRs1^E zVzFpTVBBWQrCuX8P%6;SxkXhCijo=11zS9?a4_UbQtL5mR2hCwI{fAXVCmJ zTJDiM-flsXmddkxKMxc$`y35_$HR1=X@jB=tXs@Rz{ZiFmL`WAHstab&90x8&Qz`a z7$?nfDE%Xp@xsy5^*N`cnDmbagId16@ZR{0lwQY2`74n+UVq0ExPoGS4{S?soBwQl zbC$)6cS7qdodP@L8OAgHCHFSmmlW(5ab)hAX`Gzh?@>$=)v&O}2YZ}agshYI{t{eV z^1*+FMDn=Z`B7tXM8{U`W%TbW859@QUQ!)tQXLf>c|0m~IWKF{zWFmQX4yg8HUZiq z@e#2wjnnP>J#XO|)L(4e4F7-Hrm($vb7 z_>sHiAC_(D4X88+X^+bszF>)VSK2BZwZC$CL2^rm>baJzMNIjT-wDExSS@@{XbyD39Ny{6nXw4`6lJocaEKXgNL0Rw=kctwls48zr6;J$0R)SojZnqvEg^z53tEE_J>X z&c`OS^lf3#tOhU;!64uFf_Tq3z8t;moYlo=zx&p!SI3Z@(WKxo#3>>{lopq!HT#Y7 ztfkj&Tv^k1J}&HW^6y_ncBk(%CVBV0>CBEqX7rnc={6CW$JZtw-^ORt(`Fr*WO%{v z##p)dk(dr4hscWGe{yc6hrkqLybe`a#O{!GS!;n>5td)oz5HF*V*xLOpSiSzoN!NV zvShf=q9? zNl|+i{r*mGfeE(Oiv&yR0}b=Wp4{17n(r`L&%ZB=wBQ?FNuyDJ zG1(oh^1ZGDE^B=xwMjw&H-5-)edE@S8)t1FHRuuOYunuo!$v%ot-G|;&V9%fyGss(I4ULEn|aua(-;+&$Zh(FF1IkSZqQ$JN2GS-Yhj^e^SYR)kNF(NdV#`=f*rH%O#3vntvap^~0Zj_E3QC_a6)^$Q+;$rGyhi5<}BUCcT^^!%jayX>pG8+*VTU zT$xV=(^`Ctb4&;CrGMiEC977^fJ*-Ci3(=fs zveO4_N}n`?szaaazHkt8&BSUOanEjgARr26+4=l=$!n~{@$k< z!6)G4-8r}mX#x+Qmu27=2|~>J90xd zCM^t`pK8|F>cWml4?3RHTZ!dmyM|4}z5(5AtxGndpQQcG0|KU%YKd3`q=L}j(eSa8 zq-Nb-9L5*R%^rPVW2y5tzV@q^lH=;OGgYV|yXp6LOlLH`vo;<-a+^i`*=x*JTprQW zhp8L-r}Ak1@gPH~@rjCq2kSzTHm-h&?O)(7VwVOYv>URQ8z8yp?ln`dky5H3PG_kG zZVcTG2AHp+i=0SqqsX5?7xA8`J_ADVDNX6zPo{ItCufd&XEkXyXduMz18nIebzlQ3 zgHHm6@CCu{d!^wSjtMXt)W>O?@bH)SORqsm?8Kf@U z!WYe(U#+F!#KImsgp4Q8fB)r@BO6&NvbFpDj)O3&({R z!`HmVg-wWeZ+mI93%}sb;ZI7n#ff@s ze>nYem_+uWnm6af9jm2Mk6j(+(R}gYz69Ks>;C2EGJhK>Mqb@_wj-Z6Em(2CCs8;V z8}GTQ3B%uCAecg(3mCSsWRVs-#qbBGj@8yBsFt*x(6>w_TgaJAu3}lwek$#_0Rj) zX!m!Nwu`Oh|2!3ASDh4?7YD>X%g1+hu|5;K2#6pcNJLSj))zRIqADyJWC50SpK3M1 zoW9cBXP+DEZo0vPifVbY$@s&~QE|F_O2GW-)tSTl2<3_K?Yr^0(c9?g6t|AGm%(2W zZU=1IOM=xX=xXQ7Ai0L4z)y4!s_TQ`p8*P}+nnr%8j`#P#?7S=`}+Qg_{GhI67?(& zDUvH-eVrvTy&pK=SCE_BSp*wnW3A#DqO$m|RQYKcN)BbJ)4hGu)4AE_7%50s~{Fztsgxb$cCB!EbLTL8-y@bA=bcWOc@t( zgv0CJ(0Z{P-8g0U8&R$wgNtFM?Sfa?2lAo0JGDKOsJgKT@K-IwEs9cV zHK9^&&Rnlzdkp);(&#-k&#f-`I;dqQ$5nE^rwm^WbzO>1!(a7WrJtO!dve3Ov;Fv+ z5-~{B=Aoez&#DyFeM2QIfWuMyjNc+?2t4)kA$RvlkK64MRzqoUDj( zu?IdkpIr16Y79woEW<#Tj4$TNQj$$=A1qJA9AR7VKlSsZ=Po66R*+awB>En@>x+ag zJch%Q=%TQ{exwe4Ixm>Wzm1KA79}IlvKLMj7Vtv3Q{lBzAARSjIg$bHEJ@uCI9DlfYi_|7w%b(a%-WKJJo;F|w&i4|h@LXW#JY4r zd~6_}v6i&CcGhLnaM~!%`aCEkcfny+X!cp$uy=P?>Zp3SlInT5-{Zll3*UKdiVP&;^`c$ zr}He?D1hyS@F7@GF=@A~o@$X5kR$jOi(io2nQ^qNCGC~q;iB@`@C+|eiqcbK`f(|( zpO6sMkc+!;hC_jaa^p76Tv&~_&O05f|4>nm0-z{M!Ix&;41djzomv56uVXXCt>Qqn zAYai;244)sm7Sih=+cQ&q9F0P=FMyXZ=o+1DFf zd8+%{E%lmpN^XBo zCn|4--oF6>3F9%>d7wM_68Gvkb7*eZsq=S+fmiJ4+@?~e12|q&0ur#3i!^CkXQ986 z>)hRf>yV9`H(WJZ!M5FBsVm$=DS06#RPGIEkeK1EF`ZS-T9f@BJJ<8^I$mmk&haWX z>cl$Sv;`X49Kq9)JQm};P2QOE>1)x<_uv1k%l+Tq9ED^*;B94{J65*QpQ9c08@DJO@<*N#q*Bsby6L&t@O8 z*5m%~7R;OH=U!HyD;11j9cCCxOlE=*X6uI=9r8z5>+$^>I7H1F`T*s&e8SZ#N@32t z_nD~=tPp9sWx@P3H8F7Y(p8;VH197-=X<+^r*MKRxmHR53n&QOVXEOdC#+hAaAwSg zGyCdytBo|}t*`x8VhcwM*j2x#wn{r6ruzu@ZltPZdiUyFgMZ-0((C@7*k4Xj7c$yC z9k50D4(HhJRV;)Kf=6N>p<-J6IS5qapslR*lzvI=!1SIvTQ8`8R1iQkFpI*5CkwzF zmz=rpjBh}=@t1+Cqpy9z%<$LvA)55MnXiWT)Pj`*EF*a5FSM)6Ih9O|(Mh*c11Lr~?^Q~o17gAGI})x( zu1hht{x90KW+-KtTANin9hMdDmpo_G6VI953+`sz0(f_eHpUXXVbY+TY zJeBD=9B%h?e}qLdR>lp7ZVXl&<0XiT;%nq4Fc^Veez{ubbv;OUmI`+}I#p1nIii2Qj*^76S!)!`WmkLXdp8Da1 ze>%Yv@%JbaG<4F$*;jRe8RXD~4-VpQilUpF&>kRvzr zm*~Db@nt}h#v`=yQ2p_>e(~?)VYx&8{!7^(BCSz^zv5UQ24Y+KBz|I_`!wg`z}Uz! zzd-{{%b=4_QdG3EglpB89EC=iWUxEV>I_E&AiMLD(+7HSM=c+7j;&Cb3zcs=R;=4e zAj{R+rr1_qADZT7g%W+aS$u$kAa^DlvX&q-$2q1tN1Oj~^;kG<)#t~xBI3Q-IEMT^ z2XBhYIr>4Voxj)ytrJ#OI$%~zN_OQ4MA*TEs&3}9?srg5zVUUR>??jURR<=Lh*`MN zLAK(F-XJ!~hWkEL=4oZsO<2Xox!nsH_v*c-amm^elogzog*Zef{lC14nxzK?GuV%v z#f4w0ftv6hz0khPn!z(6sn{Pnp2XAO4GAUcgI^qovO2-hOv=GhpE{kA6@YP#Wx#f) zUL93Qhq_tZYGCe|7_gnB9u5ki*6OALP#o{FuE6Z4A&lYO zMhD<`+&@&5gT4Dr(0@pOvWTw(OGwRrrg9JmfJMA@^K_kxJugeUJ*hBtkg1>Tk`nZC zf_NDei97$Kjb|(DI17k_6BPa?CygMx&|8)L8fjBvRFI-Fe2JDqbs-n3?syx@ILNu} zub*6Am!?^xj=luxPXF5#G-(f9?LI44JFRnNK5AuEXkRkKz373wBJfw?r3)?T!8d>J z8Y+q(<2M)Wm#d>mwUgEc%@nT^;v<2wSbL(!{g_#EMFt*fcuIUxiLb&XCA*1Z|Fd-T>S6$^^0Z$r*e ztUrH#MKh%+w7%kL9Ws2IY}lt|A(~OVmzJs0=6R%ce3~PuB}bTP2bQCzw+r7%+)e5P zuQ91h6O6-8BA(=w9WGbm^y3K3UyNQRxKFsuzWFF?8=yS75r24wPJP#Z$S@2PxNER6 z$uN%eToXQ-ym9@0F0CNE+9SJPJ-yrW<`8d$R?qmWmlI!2cf)#Iv)7By+Bdo(Z+J?= zK=;(mI&oNYM|5KQWJ+erJCVcwh&ruXjGK6oW}ocUCiRB;4Y0UTwMulSqwbO$E=^G4 zew5&0#RCVHKz?@L@P45*9GxC^vI%yBPI8@^qWWMd6=Ky2)??Ttd?!;)TB7}t})$TwZgAv7~JYQQIHejBP-CG%dF(a8Vc zp`fOB=)HBzFgCKxzC(IqAVzDMd3}5f%E|CqBzV_H&q4&>@}T{zg};7>~XMW8>pNjQ?sv7==Ii?ckHLrF!=XD-Q*T*S=hPhk>~ZJCX(+P6`Yd zGaT$EzJV8!;28){-LNPn&JO2^1OcjpwgyH3QdyvlnU5oTj<&5S%iML~23eJ|_s(^x zTAM5TxFn1#`8Jwl7V6G!xY6*=Q{7zC7Z+c2@YvTOO2jgZ9_;51=&U@;clar!zC5I8;M2-t@sr7|%g=mL1ebqslqRvabz5Vw1lF4}OyO&Vq z4{&hH7_%lKqSSG}zE}oGMkm4kOUZ!c2$*q~rmEzm<$N;z(zzx~C<^(I<03j;%Q2(? zY->6??K>E>zEy)q3U%JvQ9xo!pi^5U; zSrMTEfb@v?APmg7RJ(En&WR-*2fMp8Vx`qw`=8>MmQy7=56Ed9OzuTtDP?WZUS4{29Xr(%`nCh`57~ipf^qlArG{cX$K%nOhr%43w zdKd`akXyU;?#f{Bk^%xM&DT`~I&jZR`_fwHJ2cbIX3bkpX70sxi%5SQY!x|xLkbA; zL>#5Ojo@qFo%9-8XR0pDmBigP&-=`;Ctz)@Bd&k_`Ag`R(;L{=sS$hc(LxgxHk?Ke zc4UWxmlqFCIuVCgODQX5QPT~=rkH)4<^~K@_NZU| zq#VR?uPyP77Ff4Yh%2`nZr&1hy5>Asro->&JUcPaSNL*)EN{5}Ko>92lfY@oIU=%r zrb>&*szdp5P2C)p82vgTH)J7dRw0B?tD&6xW2pHg-BDgPdBRrJ6cyBG6+eW7}cyPQC4 z4cJCEv)D_71)cRG3>R(pJ3^@%ovT#`J6jh(bQ6+alf1(TJXB!S_K$gV25W-x#A<6t z6+$0)^l3 z!r_(u&fG*4YrbZ%9~Z3xlL6x=B%sMJd$4U~GJE+YS^V%T z23WUref1&I2eW?NY8%L~1-i_igEx3d0Z{0%)ftbB$+_I@lp#f5q|*j1`k?Fk{T%I~PXdg$6Kj`(M5S-J&DYn|$;p4i7}DmeJx}@Qhgk={3V*&~J=32p zyxgkC14Jlb;-guYSAF7}u)Dm>$NTEE5Uu+;S7)$ASppQ205f$wIgPgPlvEv#|bmuTLfEm6Tjxatz99*N|z+(JaAN-fbS2Wk@syj>L x4 "%02x".format(byte.toInt() and 0xff) } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt new file mode 100644 index 0000000..b65d522 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt @@ -0,0 +1,89 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.SharedFeaturePolicy +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopBuildProfileTest { + @Test + fun `standard desktop flavor keeps online features available`() { + val profile = desktopBuildProfileForFlavor("standard") + + assertEquals(DesktopFlavorStandard, profile.flavor) + assertEquals(EpistemeDesktopStandardAppName, profile.appName) + assertEquals("Standard edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy) + assertTrue(profile.featurePolicy.networkAccess) + } + + @Test + fun `oss offline desktop flavor disables network backed features`() { + val profile = desktopBuildProfileForFlavor("oss-offline") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals("Offline OSS edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + assertFalse(profile.featurePolicy.networkAccess) + assertFalse(profile.featurePolicy.aiAndCloud) + assertFalse(profile.featurePolicy.opdsCatalogs) + assertFalse(profile.featurePolicy.googleFontsDownload) + } + + @Test + fun `oss desktop flavor aliases resolve to offline oss profile`() { + val profile = desktopBuildProfileForFlavor("oss") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + } + + @Test + fun `desktop diagnostics are disabled unless explicitly enabled`() { + assertFalse(desktopDiagnosticsFlag(null)) + assertFalse(desktopDiagnosticsFlag("")) + assertFalse(desktopDiagnosticsFlag("false")) + assertFalse(desktopDiagnosticsFlag("1")) + + assertTrue(desktopDiagnosticsFlag("true")) + assertTrue(desktopDiagnosticsFlag(" TRUE ")) + } + + @Test + fun `bundled webview detection requires cef binaries`() { + val dir = Files.createTempDirectory("episteme-kcef-test").toFile() + try { + val windowsX64 = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + assertFalse(isBundledDesktopWebViewPresent(dir, windowsX64)) + File(dir, "jcef.dll").writeText("jcef") + File(dir, "libcef.dll").writeText("cef") + + assertTrue(isBundledDesktopWebViewPresent(dir, windowsX64)) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `linux bundled webview detection requires cef shared library and resources`() { + val dir = Files.createTempDirectory("episteme-linux-kcef-test").toFile() + try { + val linuxX64 = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + assertFalse(isBundledDesktopWebViewPresent(dir, linuxX64)) + + File(dir, "libcef.so").writeText("cef") + File(dir, "chrome-sandbox").writeText("sandbox") + File(dir, "icudtl.dat").writeText("icu") + File(dir, "locales").mkdir() + + assertTrue(isBundledDesktopWebViewPresent(dir, linuxX64)) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt index 3725197..365d922 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt @@ -76,6 +76,21 @@ class DesktopCustomFontStoreTest { assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]""")) } + @Test + fun `download google font fails before network when downloads are disabled`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val store = DesktopCustomFontStore( + fontsDir = File(tempRoot, "store"), + googleFontsDownloadAvailable = { false } + ) + + assertTrue(store.downloadGoogleFont("Inter").isFailure) + } finally { + tempRoot.deleteRecursively() + } + } + private fun File.toFontItem(): CustomFontItem { return CustomFontItem( id = nameWithoutExtension, diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt index 17a64ed..d54e44a 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt @@ -24,6 +24,9 @@ class DesktopFolderMetadataExtractorTest { Direct EPUB Ada Lovelace + <p>Metadata summary</p> + + @@ -42,12 +45,49 @@ class DesktopFolderMetadataExtractorTest { val enriched = result.books.single() assertEquals("Direct EPUB", enriched.title) assertEquals("Ada Lovelace", enriched.author) + assertEquals("

Metadata summary

", enriched.description) + assertEquals("Computing Notes", enriched.seriesName) + assertEquals(2.0, enriched.seriesIndex) + assertEquals("Direct EPUB", enriched.originalTitle) + assertEquals("Ada Lovelace", enriched.originalAuthor) + assertEquals("Computing Notes", enriched.originalSeriesName) + assertEquals(2.0, enriched.originalSeriesIndex) + assertEquals("

Metadata summary

", enriched.originalDescription) + assertEquals(epub.lastModified(), enriched.fileContentModifiedTimestamp) assertTrue(enriched.folderTextMetadataParsed) assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) assertEquals(1, result.stats.updatedBooks) assertEquals(1, result.stats.coversUpdated) } + @Test + fun `opened epub gets embedded cover`() = withCoverCacheDir { tempDir -> + val epub = File(tempDir, "opened.epub") + writeEpub( + target = epub, + opf = """ + + + Opened EPUB + Mary Shelley + + + + + + + """.trimIndent() + ) + val book = bookFor(epub, FileType.EPUB, title = null) + + val enriched = DesktopFolderMetadataExtractor.enrichOpenedBook(book) + + assertEquals("Opened EPUB", enriched.title) + assertEquals("Mary Shelley", enriched.author) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + } + @Test fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir -> val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt new file mode 100644 index 0000000..ade9952 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt @@ -0,0 +1,108 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import com.aryan.reader.shared.SharedFolderBookMetadata +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.SyncedFolder +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DesktopLocalFolderSyncTest { + @Test + fun `metadata-only sync imports sidecar metadata without scanning physical files`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + File(root, "New.pdf").writeText("%PDF") + val existingFile = File(root, "Existing.pdf") + val existingId = "local_Existing.pdf" + writeMetadataSidecar( + root = root, + metadata = metadata( + id = existingId, + title = "Remote Title", + progress = 72f, + modified = 2_000L + ) + ) + + val existingBook = BookItem( + id = existingId, + path = existingFile.absolutePath, + type = FileType.PDF, + displayName = existingFile.name, + timestamp = 100L, + title = "Local Title", + progressPercentage = 5f, + sourceFolder = root.absolutePath + ) + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(existingBook), + syncedFolders = listOf(syncedFolder(root)) + ), + shelfRefs = emptyList(), + nowMillis = 3_000L, + metadataOnly = true + ) + + assertEquals(1, result.state.rawLibraryBooks.size) + val syncedBook = result.state.rawLibraryBooks.single() + assertEquals(existingId, syncedBook.id) + assertEquals("Local Title", syncedBook.title) + assertEquals(72f, syncedBook.progressPercentage) + assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_New.pdf" }) + assertEquals(0, result.stats.scannedFiles) + assertEquals(0, result.stats.newBooks) + assertEquals(0, result.stats.removedBooks) + assertEquals(1, result.stats.remoteMetadataUpdates) + } finally { + root.deleteRecursively() + } + } + + private fun syncedFolder(root: File): SyncedFolder { + return SyncedFolder( + uriString = root.absolutePath, + name = root.name, + lastScanTime = 0L, + allowedFileTypes = setOf(FileType.PDF) + ) + } + + private fun writeMetadataSidecar(root: File, metadata: SharedFolderBookMetadata) { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } + File(syncDir, ".${metadata.bookId}.json").writeText(metadata.toJsonString()) + } + + private fun metadata( + id: String, + title: String, + progress: Float, + modified: Long + ): SharedFolderBookMetadata { + return SharedFolderBookMetadata( + bookId = id, + title = title, + author = null, + displayName = "Existing.pdf", + type = FileType.PDF.name, + lastChapterIndex = null, + lastPage = null, + lastPositionCfi = null, + progressPercentage = progress, + isRecent = true, + lastModifiedTimestamp = modified, + bookmarksJson = null, + locatorBlockIndex = null, + locatorCharOffset = null, + customName = null, + highlightsJson = null + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt index a0b96cb..3a7f0e3 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt @@ -4,6 +4,7 @@ import java.io.File import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class DesktopOpdsRepositoryTest { @@ -27,6 +28,45 @@ class DesktopOpdsRepositoryTest { assertEquals("pass", custom.password) } + @Test + fun `desktop opds http blocks before network in offline flavor`() { + withSystemProperty(DesktopFlavorProperty, DesktopFlavorOssOffline) { + assertFailsWith { + DesktopOpdsHttp.fetchString("https://example.org/opds", null, null) + } + } + } + + @Test + fun `desktop opds http creates basic authorization header for challenged catalogs`() { + assertEquals( + "Basic dXNlcjpwYXNz", + DesktopOpdsHttp.authorizationHeaderForChallenge( + challenge = "Basic realm=\"Catalog\"", + url = "https://example.org/opds", + username = "user", + password = "pass" + ) + ) + } + + @Test + fun `desktop opds http creates digest authorization header for challenged catalogs`() { + assertEquals( + "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"abcdef\", " + + "uri=\"/dir/index.atom?x=1\", response=\"ca833912ad1f4339630e23476d538d67\", " + + "qop=auth, nc=00000001, cnonce=\"0a4f113b\", opaque=\"xyz\"", + DesktopOpdsHttp.authorizationHeaderForChallenge( + challenge = "Digest realm=\"testrealm@host.com\", nonce=\"abcdef\", qop=\"auth\", opaque=\"xyz\"", + url = "https://example.org/dir/index.atom?x=1", + username = "Mufasa", + password = "Circle Of Life", + cnonce = "0a4f113b", + nonceCount = "00000001" + ) + ) + } + private fun DesktopOpdsRepository.addCatalogForTest( title: String, url: String, @@ -53,4 +93,26 @@ class DesktopOpdsRepositoryTest { dir.deleteRecursively() } } + + private fun withSystemProperty( + key: String, + value: String?, + block: () -> Unit + ) { + val previous = System.getProperty(key) + try { + if (value == null) { + System.clearProperty(key) + } else { + System.setProperty(key, value) + } + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt new file mode 100644 index 0000000..fd47373 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt @@ -0,0 +1,52 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderTheme +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPdfThemeTest { + @Test + fun `desktop pdf defaults to vertical display mode`() { + assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode) + assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap) + } + + @Test + fun `page background follows android pdf theme defaults`() { + val noTheme = ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false) + val reverse = ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true) + val sepia = ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false) + + assertEquals(Color.White, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.VERTICAL_SCROLL)) + assertEquals(Color.Black, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.PAGINATION)) + assertEquals(Color.Black, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.VERTICAL_SCROLL)) + assertEquals(Color.White, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.PAGINATION)) + assertEquals(Color(0xFFFBF0D9), desktopPdfPageBackgroundColor(sepia, PdfDisplayMode.VERTICAL_SCROLL)) + } + + @Test + fun `vertical viewport uses app gap color only when page gaps are visible`() { + val pageBackground = Color.White + val gapBackground = Color(0xFFE2E2E2) + + assertEquals( + gapBackground, + desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackground, + gapBackgroundColor = gapBackground, + isPageGapVisible = true + ) + ) + assertEquals( + pageBackground, + desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackground, + gapBackgroundColor = gapBackground, + isPageGapVisible = false + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt new file mode 100644 index 0000000..170cedc --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt @@ -0,0 +1,72 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPlatformPathsTest { + @Test + fun `desktop platform detects linux x64 resource names`() { + val platform = currentDesktopPlatform(osName = "Linux", osArch = "amd64") + + assertEquals(DesktopOperatingSystem.LINUX, platform.os) + assertEquals(DesktopArchitecture.X64, platform.architecture) + assertEquals("kcef-bundle-linux-x64", platform.kcefBundleDirectoryName) + assertEquals("linux-x64-v8", platform.pdfiumDirectoryName) + assertEquals("lib", platform.pdfiumLibraryDirectoryName) + assertEquals("libpdfium.so", platform.pdfiumLibraryFileName) + } + + @Test + fun `desktop platform keeps existing windows resource names`() { + val platform = currentDesktopPlatform(osName = "Windows 11", osArch = "amd64") + + assertEquals(DesktopOperatingSystem.WINDOWS, platform.os) + assertEquals(DesktopArchitecture.X64, platform.architecture) + assertEquals("kcef-bundle", platform.kcefBundleDirectoryName) + assertEquals("win-x64-v8", platform.pdfiumDirectoryName) + assertEquals("bin", platform.pdfiumLibraryDirectoryName) + assertEquals("pdfium.dll", platform.pdfiumLibraryFileName) + } + + @Test + fun `linux user directories follow xdg environment variables`() { + val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val env = mapOf( + "XDG_DATA_HOME" to "/tmp/xdg-data", + "XDG_CONFIG_HOME" to "/tmp/xdg-config", + "XDG_CACHE_HOME" to "/tmp/xdg-cache" + ) + + assertEquals("/tmp/xdg-data/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/tmp/xdg-config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/tmp/xdg-cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) + } + + @Test + fun `linux user directories ignore relative xdg environment values`() { + val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val env = mapOf( + "XDG_DATA_HOME" to "relative-data", + "XDG_CONFIG_HOME" to "relative-config", + "XDG_CACHE_HOME" to "relative-cache" + ) + + assertEquals("/home/reader/.local/share/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/home/reader/.config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/home/reader/.cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) + } + + @Test + fun `windows user directories keep appdata compatible root`() { + val platform = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming") + + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserDataRoot(platform, env::get, "C:/Users/reader").portablePath()) + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserConfigRoot(platform, env::get, "C:/Users/reader").portablePath()) + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserCacheRoot(platform, env::get, "C:/Users/reader").portablePath()) + } +} + +private fun java.io.File.portablePath(): String { + return path.replace('\\', '/') +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt new file mode 100644 index 0000000..68fcdae --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt @@ -0,0 +1,182 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.pdf.PdfZoomSpec +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopReaderDefaultsTest { + + @Test + fun `desktop open book dialog accepts every shared desktop readable format`() { + assertEquals( + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), + desktopBookFileTypesForDialog() + ) + assertTrue(FileType.PDF in desktopBookFileTypesForDialog()) + } + + @Test + fun `desktop uses global reader defaults when book has no local settings`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val book = bookItem("without-local") + + assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop keeps local book reader settings ahead of global defaults`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia") + val book = bookItem("with-local").copy(readerSettings = local) + + assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop pdf zoom allows deeper page magnification`() { + val sharedDefaultMax = PdfZoomSpec().max + val letterPageScale = DesktopPdfZoomSpec.safeRenderScale( + pageWidth = 612f, + pageHeight = 792f, + requestedScale = 6f + ) + + assertEquals(8f, DesktopPdfZoomSpec.max) + assertTrue(letterPageScale > sharedDefaultMax) + } + + @Test + fun `desktop pdf touchpad zoom factors zoom in and out`() { + val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f) + + assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f) + assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f) + assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f)) + assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) + } + + @Test + fun `desktop paginated pdf page changes avoid high resolution first render`() { + assertEquals( + DesktopPdfPaginationFastFirstRenderMaxScale, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale( + requestedScale = 6f, + hasPageRender = false, + isOpeningRender = true + ) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true) + ) + assertEquals( + 1.25f, + desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false) + ) + assertEquals( + 0.75f, + desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false) + ) + } + + @Test + fun `desktop pdf anchored zoom keeps cursor content stable`() { + assertEquals( + 300, + desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 25, + desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f) + ) + assertEquals( + 100, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 200, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + IntOffset(100, 100), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + assertEquals( + IntOffset(0, 0), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + val offCenterPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset(20f, 30f), + pageRootOffset = Offset(120f, 230f), + anchor = Offset(250f, 450f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected off-center pivot") + assertEquals(0.3f, offCenterPivot.x, 0.0001f) + assertEquals(0.25f, offCenterPivot.y, 0.0001f) + + val clampedPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero, + anchor = Offset(900f, -20f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected clamped pivot") + assertEquals(1f, clampedPivot.x, 0.0001f) + assertEquals(0f, clampedPivot.y, 0.0001f) + + val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 0f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected first page document translation") + assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f) + assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f) + + val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 900f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected second page document translation") + assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f) + assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f) + } + + private fun bookItem(id: String): BookItem { + return BookItem( + id = id, + path = "C:/Books/$id.epub", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt new file mode 100644 index 0000000..23d59ad --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt @@ -0,0 +1,58 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.ReaderFeatureSurface +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopStartupTest { + @Test + fun `startup splash uses compact branded feedback`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard")) + + assertEquals(EpistemeDesktopWindowTitle, spec.title) + assertTrue(spec.message.isNotBlank()) + assertTrue(spec.width in 320..480) + assertTrue(spec.height in 180..280) + } + + @Test + fun `oss startup splash uses oss branding`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline")) + + assertEquals(EpistemeDesktopOssAppName, spec.title) + } + + @Test + fun `embedded webview starts only for epub backed reader surfaces`() { + assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.EPUB_READER)) + assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.TEXT_READER)) + assertFalse(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.PDF_VIEWER)) + assertFalse(shouldRequestDesktopWebViewRuntime(null)) + } + + @Test + fun `embedded webview startup skips terminal runtime states`() { + assertFalse(shouldStartDesktopWebViewRuntime(requested = false, state = DesktopWebViewRuntimeState())) + assertTrue(shouldStartDesktopWebViewRuntime(requested = true, state = DesktopWebViewRuntimeState())) + assertFalse( + shouldStartDesktopWebViewRuntime( + requested = true, + state = DesktopWebViewRuntimeState(initialized = true) + ) + ) + assertFalse( + shouldStartDesktopWebViewRuntime( + requested = true, + state = DesktopWebViewRuntimeState(restartRequired = true) + ) + ) + assertFalse( + shouldStartDesktopWebViewRuntime( + requested = true, + state = DesktopWebViewRuntimeState(errorMessage = "missing bundle") + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt new file mode 100644 index 0000000..354ecfa --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowPolishTest.kt @@ -0,0 +1,46 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopWindowPolishTest { + @Test + fun `desktop window defaults use app branding and a useful first launch size`() { + val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("standard")) + + assertEquals(EpistemeDesktopWindowTitle, defaults.title) + assertEquals(EpistemeDesktopWindowIconResource, defaults.iconResourcePath) + assertTrue(defaults.defaultSize.width.value > defaults.minimumSize.width.toFloat()) + assertTrue(defaults.defaultSize.height.value > defaults.minimumSize.height.toFloat()) + assertEquals(EpistemeDesktopWindowMinimumWidthPx, defaults.minimumSize.width) + assertEquals(EpistemeDesktopWindowMinimumHeightPx, defaults.minimumSize.height) + } + + @Test + fun `oss desktop window defaults use oss branding`() { + val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("oss-offline")) + + assertEquals(EpistemeDesktopOssAppName, defaults.title) + } + + @Test + fun `desktop chrome colors choose dark mode from dark theme surfaces`() { + val darkChrome = desktopWindowChromeColors( + captionColor = Color(0xFF12140E), + textColor = Color(0xFFE2E3D8), + borderColor = Color(0xFF0C0F09) + ) + + val lightChrome = desktopWindowChromeColors( + captionColor = Color(0xFFF9FAEF), + textColor = Color(0xFF1A1C16), + borderColor = Color(0xFFFFFFFF) + ) + + assertTrue(darkChrome.useDarkMode) + assertFalse(lightChrome.useDarkMode) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt new file mode 100644 index 0000000..24a7ba6 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt @@ -0,0 +1,37 @@ +package com.aryan.reader.desktop + +import kotlin.io.path.createTempFile +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopWindowStateStoreTest { + + @Test + fun `desktop window state round trips through config store`() { + val file = createTempFile("episteme-window-state", ".json").toFile() + val store = DesktopWindowStateStore(file) + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 1440f, + heightDp = 900f, + xDp = 120f, + yDp = 80f + ) + + store.save(snapshot) + + assertEquals(snapshot, store.load()) + } + + @Test + fun `desktop window state clamps too small saved bounds`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 12f, + heightDp = 34f + ).sanitized() + + assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp) + assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp) + } +} diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt new file mode 100644 index 0000000..b3a15a9 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.android.kt @@ -0,0 +1,5 @@ +package com.aryan.reader.shared.reader + +internal actual val SharedReaderDiagnosticsEnabled: Boolean = false + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt new file mode 100644 index 0000000..4ec53da --- /dev/null +++ b/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.android.kt @@ -0,0 +1,19 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +internal actual fun SharedReaderModalLayer( + onDismiss: () -> Unit, + level: SharedReaderModalLevel, + content: @Composable () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + content() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt index 8ebb2de..9acc93d 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt @@ -1,6 +1,7 @@ package com.aryan.reader.shared import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSearchOptions @@ -9,6 +10,7 @@ sealed interface LibraryAction { data class SortChanged(val sortOrder: SortOrder) : LibraryAction data class FiltersChanged(val filters: LibraryFilters) : LibraryAction data class BookSelectionToggled(val bookId: String) : LibraryAction + data class BookSelectionReplaced(val bookIds: Set) : LibraryAction data object SelectionCleared : LibraryAction data class ShelfSelectionToggled(val shelfId: String) : LibraryAction data object ShelfSelectionCleared : LibraryAction @@ -24,8 +26,18 @@ sealed interface ReaderAction { data class GoToProgress(val progress: Float) : ReaderAction data class GoToChapter(val chapterIndex: Int) : ReaderAction data class GoToLocator(val locator: ReaderLocator) : ReaderAction + data class JumpToPage(val pageIndex: Int) : ReaderAction + data class JumpToPageNumber(val pageNumber: Int) : ReaderAction + data class JumpToChapter(val chapterIndex: Int) : ReaderAction + data class JumpToLocator(val locator: ReaderLocator) : ReaderAction data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction data class GoToSearchResult(val resultIndex: Int) : ReaderAction + data class JumpToSearchResult(val resultIndex: Int) : ReaderAction + data object JumpToNextSearchResult : ReaderAction + data object JumpToPreviousSearchResult : ReaderAction + data object JumpBack : ReaderAction + data object JumpForward : ReaderAction + data object JumpHistoryCleared : ReaderAction data class SearchChanged(val query: String) : ReaderAction data object SearchOpened : ReaderAction data object SearchClosed : ReaderAction @@ -71,11 +83,14 @@ sealed interface AppAction { data object AllTabsClosed : AppAction data class HomePinToggled(val bookId: String) : AppAction data class LibraryPinToggled(val bookId: String) : AppAction + data class ReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction + data class PdfReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction data class ReaderToolOrderChanged(val toolOrder: List) : AppAction data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction + data class PdfHighlighterPaletteChanged(val palette: SharedPdfHighlighterPalette) : AppAction data class ReaderTtsReplacementPreferencesChanged( val preferences: ReaderTtsReplacementPreferences, ) : AppAction diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt index 35ba1c7..27adb86 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt @@ -1,6 +1,8 @@ package com.aryan.reader.shared import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import com.aryan.reader.shared.reader.ReaderSettings data class BannerMessage( val message: String, @@ -104,7 +106,7 @@ data class SharedReaderScreenState( val pinnedLibraryBookIds: Set = emptySet(), val libraryFilters: LibraryFilters = LibraryFilters(), val recentFilesLimit: Int = 0, - val isTabsEnabled: Boolean = false, + val isTabsEnabled: Boolean = true, val openTabIds: List = emptyList(), val openTabs: List = emptyList(), val activeTabBookId: String? = null, @@ -117,9 +119,12 @@ data class SharedReaderScreenState( val appTextDimFactorDark: Float = 1.0f, val appSeedColor: Color? = null, val customAppThemes: List = emptyList(), + val readerDefaultSettings: ReaderSettings = ReaderSettings(), + val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), val allTags: List = emptyList(), val showTagSelectionDialogFor: Set = emptySet(), val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences() ) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt index 6fd1dd0..4fc41d6 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt @@ -31,6 +31,43 @@ data class FileTypeCapability( } object SharedFileCapabilities { + private val codeOrDataExtensions = setOf( + "csv", + "tsv", + "json", + "xml", + "log", + "java", + "kt", + "py", + "js", + "cpp", + "c", + "cs", + "rb", + "go" + ) + + private val manualOnlyReaderMimeTypes = setOf( + "text/csv", + "text/comma-separated-values", + "text/tab-separated-values", + "application/json", + "application/xml", + "text/xml", + "text/x-java-source", + "text/x-python", + "text/x-kotlin", + "text/javascript", + "application/javascript", + "text/x-c", + "text/x-c++", + "text/x-csharp", + "text/x-ruby", + "text/x-go", + "text/x-log" + ) + val all: List = listOf( FileTypeCapability( type = FileType.EPUB, @@ -122,6 +159,13 @@ object SharedFileCapabilities { extensions = setOf("fodt"), androidSurface = ReaderFeatureSurface.EPUB_READER, desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.PPTX, + displayName = "PPTX", + extensions = setOf("pptx"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = null ) ) @@ -129,6 +173,23 @@ object SharedFileCapabilities { private val typesByExtension: Map = all .flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } } .toMap() + private val mimeTypesByType: Map = mapOf( + FileType.PDF to "application/pdf", + FileType.EPUB to "application/epub+zip", + FileType.MOBI to "application/x-mobipocket-ebook", + FileType.MD to "text/markdown", + FileType.TXT to "text/plain", + FileType.HTML to "text/html", + FileType.FB2 to "application/x-fictionbook+xml", + FileType.CBZ to "application/zip", + FileType.CBR to "application/zip", + FileType.CB7 to "application/zip", + FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + FileType.ODT to "application/vnd.oasis.opendocument.text", + FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml" + ) + val knownFileTypes: Set = all.mapTo(mutableSetOf()) { it.type } fun capabilityFor(type: FileType): FileTypeCapability? { return capabilitiesByType[type] @@ -138,12 +199,63 @@ object SharedFileCapabilities { return capabilityFor(type)?.displayName ?: type.name } + fun primaryExtensionFor(type: FileType): String? { + return capabilityFor(type)?.extensions?.firstOrNull() + } + + fun mimeTypeFor(type: FileType): String? { + return mimeTypesByType[type] + } + fun fileTypeForName(fileName: String): FileType { - val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") - .substringBefore('?') - .substringBefore('#') - .lowercase() - return typesByExtension[extension] ?: FileType.UNKNOWN + return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN + } + + fun resolveFileTypeForName(fileName: String?): FileType? { + val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null + val effectiveName = normalized.withTransparentTextSuffix() + return fileTypeForEffectiveName(effectiveName) + } + + fun isCodeOrDataFileName(fileName: String): Boolean { + return fileName.normalizedFileName() + .withTransparentTextSuffix() + .extensionAfterLastDot() in codeOrDataExtensions + } + + fun isManualOnlyReaderFileName(fileName: String?): Boolean { + return fileName?.let(::isCodeOrDataFileName) ?: false + } + + fun isManualOnlyReaderMimeType(mimeType: String?): Boolean { + val normalized = mimeType?.lowercase() ?: return false + return normalized in manualOnlyReaderMimeTypes + } + + fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean { + if (isManualOnlyReaderFileName(name)) return false + if (resolveFileTypeForName(name) != null) return true + return !isManualOnlyReaderMimeType(mimeType) + } + + fun fileExtensionSuffixForName(fileName: String?): String? { + val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null + val effectiveName = normalized.withTransparentTextSuffix() + val effectiveSuffix = when { + effectiveName.endsWith(".fb2.zip") -> ".fb2.zip" + effectiveName.endsWith(".markdown") -> ".markdown" + effectiveName.endsWith(".xhtml") -> ".xhtml" + effectiveName.extensionAfterLastDot() != null && resolveFileTypeForName(effectiveName) != null -> { + ".${effectiveName.extensionAfterLastDot()}" + } + else -> null + } ?: return null + + return if (effectiveName != normalized && normalized.endsWith(".txt")) { + "$effectiveSuffix.txt" + } else { + effectiveSuffix + } } fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? { @@ -177,4 +289,30 @@ object SharedFileCapabilities { .filter { it.isReadableOnAndroid && !it.isReadableOnDesktop } .map { it.type } } + + private fun fileTypeForEffectiveName(fileName: String): FileType? { + if (fileName.endsWith(".fb2.zip")) return FileType.FB2 + val extension = fileName.extensionAfterLastDot() ?: return null + if (extension in codeOrDataExtensions) return FileType.HTML + return typesByExtension[extension] + } + + private fun String.normalizedFileName(): String { + return trim() + .substringBefore('?') + .substringBefore('#') + .lowercase() + } + + private fun String.withTransparentTextSuffix(): String { + if (!endsWith(".txt")) return this + val innerName = removeSuffix(".txt") + if (innerName.isBlank() || !innerName.contains('.')) return this + return if (fileTypeForEffectiveName(innerName) != null) innerName else this + } + + private fun String.extensionAfterLastDot(): String? { + val dotIndex = lastIndexOf('.') + return if (dotIndex > 0 && dotIndex < lastIndex) substring(dotIndex + 1) else null + } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt new file mode 100644 index 0000000..1ab914a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ImportContracts.kt @@ -0,0 +1,123 @@ +package com.aryan.reader.shared + +enum class SharedImportDecisionStatus { + IMPORTABLE, + DUPLICATE, + UNSUPPORTED +} + +data class SharedImportDecision( + val file: ImportedBookFile, + val id: String, + val type: FileType, + val status: SharedImportDecisionStatus +) + +data class SharedImportPlan( + val decisions: List, + val importedBooks: List +) { + val supportedFiles: List + get() = decisions + .filterNot { it.status == SharedImportDecisionStatus.UNSUPPORTED } + .map { it.file } + + val importableFiles: List + get() = supportedFiles + + val importedFiles: List + get() = decisions + .filter { it.status == SharedImportDecisionStatus.IMPORTABLE } + .map { it.file } + + val duplicateFiles: List + get() = decisions + .filter { it.status == SharedImportDecisionStatus.DUPLICATE } + .map { it.file } + + val unsupportedFiles: List + get() = decisions + .filter { it.status == SharedImportDecisionStatus.UNSUPPORTED } + .map { it.file } + + val importedCount: Int get() = importedBooks.size + val duplicateCount: Int get() = duplicateFiles.size + val unsupportedCount: Int get() = unsupportedFiles.size +} + +data class SharedImportOutcomeCounts( + val addedCount: Int = 0, + val duplicateCount: Int = 0, + val unsupportedCount: Int = 0, + val failedCount: Int = 0 +) + +data class SharedImportFeedback( + val message: String, + val isError: Boolean +) + +object SharedImportPlanner { + fun plan( + files: List, + existingBookIds: Set, + platform: ReaderPlatform, + nowMillis: Long = currentTimestamp() + ): SharedImportPlan { + val seenIds = existingBookIds.toMutableSet() + val decisions = files.map { file -> + val id = stableImportId(file) + val type = SharedFileCapabilities.fileTypeForName(file.name) + val status = when { + !SharedFileCapabilities.canOpen(type, platform) -> SharedImportDecisionStatus.UNSUPPORTED + !seenIds.add(id) -> SharedImportDecisionStatus.DUPLICATE + else -> SharedImportDecisionStatus.IMPORTABLE + } + SharedImportDecision( + file = file, + id = id, + type = type, + status = status + ) + } + val importedBooks = decisions.mapIndexedNotNull { index, decision -> + if (decision.status != SharedImportDecisionStatus.IMPORTABLE) return@mapIndexedNotNull null + val file = decision.file + BookItem( + id = decision.id, + path = file.localPath ?: file.uriString, + type = decision.type, + displayName = file.name, + timestamp = nowMillis + index, + title = file.name.substringBeforeLast('.'), + fileSize = file.size, + sourceFolder = file.sourceFolder, + isRecent = false + ) + } + return SharedImportPlan(decisions, importedBooks) + } + + fun feedbackForCounts( + counts: SharedImportOutcomeCounts, + importedMessage: String, + duplicateMessage: String, + unsupportedMessage: String, + failedMessage: String + ): SharedImportFeedback { + val message = when { + counts.addedCount > 0 -> importedMessage + counts.duplicateCount > 0 -> duplicateMessage + counts.unsupportedCount > 0 -> unsupportedMessage + else -> failedMessage + } + return SharedImportFeedback( + message = message, + isError = counts.addedCount == 0 && counts.duplicateCount == 0 + ) + } + + fun stableImportId(file: ImportedBookFile): String { + return file.id ?: file.localPath ?: file.uriString ?: file.name + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt index fdb7523..e66d5a5 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt @@ -1,13 +1,14 @@ package com.aryan.reader.shared +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport import com.aryan.reader.shared.reader.ReaderBookmark import com.aryan.reader.shared.reader.ReaderSettings enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN } -val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7) +val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX) val EPUB_READER_FILE_TYPES = setOf( FileType.EPUB, @@ -68,7 +69,7 @@ data class SyncedFolder( val uriString: String, val name: String, val lastScanTime: Long, - val allowedFileTypes: Set = FileType.entries.toSet() + val allowedFileTypes: Set = SharedFileCapabilities.knownFileTypes ) data class BookItem( @@ -80,18 +81,27 @@ data class BookItem( val coverImagePath: String? = null, val title: String? = null, val author: String? = null, + val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null, val progressPercentage: Float? = null, val isRecent: Boolean = true, val fileSize: Long = 0L, + val fileContentModifiedTimestamp: Long = 0L, val sourceFolder: String? = null, val folderTextMetadataParsed: Boolean = false, val seriesName: String? = null, val seriesIndex: Double? = null, val tags: List = emptyList(), val lastPageIndex: Int? = null, + val readerPosition: ReaderLocator? = null, val readerSettings: ReaderSettings? = null, val readerBookmarks: List = emptyList(), - val readerHighlights: List = emptyList() + val readerHighlights: List = emptyList(), + val pdfReaderViewport: SharedPdfReaderViewport? = null ) data class Shelf( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt index 04b56dc..6c66318 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt @@ -28,29 +28,18 @@ class LibraryProjector { fun withImportedFiles(state: LibraryState, files: List): LibraryState { if (files.isEmpty()) return state - val now = currentTimestamp() - val existingIds = state.books.mapTo(mutableSetOf()) { it.id } - val imported = files.mapIndexedNotNull { index, file -> - val id = file.path ?: file.name - if (!existingIds.add(id)) { - null - } else { - BookItem( - id = id, - path = file.path, - type = file.name.toFileType(), - displayName = file.name, - timestamp = now + index, - title = file.name.substringBeforeLast('.'), - fileSize = file.size, - sourceFolder = file.sourceFolder ?: file.path?.parentPath(), - isRecent = false - ) - } - } + val plan = SharedImportPlanner.plan( + files = files.map { it.toImportedBookFile() }, + existingBookIds = state.books.mapTo(mutableSetOf()) { it.id }, + platform = ReaderPlatform.DESKTOP + ) return state.copy( - books = imported + state.books, - message = if (imported.isEmpty()) "Those files are already in the desktop library." else "Imported ${imported.size} file(s). Reader support comes later." + books = plan.importedBooks + state.books, + message = when { + plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s). Reader support comes later." + plan.unsupportedCount > 0 -> "No supported files were imported." + else -> "Those files are already in the desktop library." + } ) } @@ -136,12 +125,6 @@ class LibraryProjector { } } -private fun String.parentPath(): String? { - val normalized = replace('\\', '/') - val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "") - return parent.ifBlank { null } -} - private fun String.folderDisplayName(): String { return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" } } @@ -153,6 +136,16 @@ data class ImportedFile( val sourceFolder: String? = null ) +private fun ImportedFile.toImportedBookFile(): ImportedBookFile { + return ImportedBookFile( + name = name, + uriString = null, + localPath = path, + size = size, + sourceFolder = sourceFolder + ) +} + expect fun currentTimestamp(): Long fun String.toFileType(): FileType { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt index 2ee4fb3..8155c5c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt @@ -35,6 +35,7 @@ class SharedLibraryStateProjector( fun project(input: SharedLibraryProjectionInput): SharedReaderScreenState { val current = input.state val allLibraryBooks = input.booksFromStore + val syncedFolders = current.syncedFolders.withSourceFolderFallbacks(allLibraryBooks) val queried = filterBySearch(allLibraryBooks, current.searchQuery) val filtered = applyLibraryFilters(queried, current.libraryFilters) val sortedLibraryBooks = sortBooks(filtered, current.sortOrder) @@ -54,7 +55,7 @@ class SharedLibraryStateProjector( shelfRefs = input.shelfRefs, tags = input.tags, sortOrder = current.sortOrder, - syncedFolders = current.syncedFolders + syncedFolders = syncedFolders ) val validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id } val viewingShelfId = current.viewingShelfId?.takeIf { it in validShelfIds } @@ -89,7 +90,8 @@ class SharedLibraryStateProjector( openTabIds = openTabIds, activeTabBookId = activeTabBookId, booksAvailableForAdding = booksAvailableForAdding, - allTags = input.tags + allTags = input.tags, + syncedFolders = syncedFolders ) } @@ -252,6 +254,21 @@ class SharedLibraryStateProjector( ) } +private fun List.withSourceFolderFallbacks(books: List): List { + val knownFolders = mapTo(linkedSetOf()) { it.uriString } + val missingFolders = books + .mapNotNull { it.sourceFolder?.takeIf(String::isNotBlank) } + .filterTo(linkedSetOf()) { knownFolders.add(it) } + .map { sourceFolder -> + SyncedFolder( + uriString = sourceFolder, + name = sourceFolder.folderDisplayName(), + lastScanTime = 0L + ) + } + return if (missingFolders.isEmpty()) this else this + missingFolders +} + private fun String.folderDisplayName(): String { return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" } } @@ -303,43 +320,24 @@ fun SharedReaderScreenState.withImportedFiles( now: Long = currentTimestamp() ): SharedReaderScreenState { if (files.isEmpty()) return this - val existingIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id } - val imported = files.mapIndexedNotNull { index, file -> - val id = file.localPath ?: file.uriString ?: file.name - if (!existingIds.add(id)) { - null - } else { - BookItem( - id = id, - path = file.localPath ?: file.uriString, - type = file.name.toFileType(), - displayName = file.name, - timestamp = now + index, - title = file.name.substringBeforeLast('.'), - fileSize = file.size, - sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(), - isRecent = false - ) - } - } + val plan = SharedImportPlanner.plan( + files = files, + existingBookIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id }, + platform = ReaderPlatform.DESKTOP, + nowMillis = now + ) return copy( - rawLibraryBooks = imported + rawLibraryBooks, + rawLibraryBooks = plan.importedBooks + rawLibraryBooks, bannerMessage = BannerMessage( - if (imported.isEmpty()) { - "Those files are already in the library." - } else { - "Imported ${imported.size} file(s)." + when { + plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s)." + plan.unsupportedCount > 0 -> "No supported files were imported." + else -> "Those files are already in the library." } ) ) } -private fun String.parentPath(): String? { - val normalized = replace('\\', '/') - val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "") - return parent.ifBlank { null } -} - private fun List.withPinnedFirst(pinnedBookIds: Set): List { if (pinnedBookIds.isEmpty()) return this return withIndex() diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt index ec8fed1..dc28e61 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt @@ -17,13 +17,34 @@ import kotlinx.serialization.json.longOrNull const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData" const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations" +const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_" internal expect fun localFolderSyncSha256ShortHex(value: String): String +fun localFolderSyncSidecarStem(bookId: String): String { + return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId) +} + +fun localFolderSyncMetadataFileName(bookId: String): String { + return ".${localFolderSyncSidecarStem(bookId)}.json" +} + +fun localFolderSyncMetadataTempFileName(bookId: String): String { + return ".${localFolderSyncSidecarStem(bookId)}.tmp" +} + +fun localFolderSyncAnnotationFileName(bookId: String): String { + return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.json" +} + +fun localFolderSyncAnnotationTempFileName(bookId: String): String { + return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.tmp" +} + data class SharedFolderBookMetadata( val bookId: String, - val title: String?, - val author: String?, + val title: String? = null, + val author: String? = null, val displayName: String, val type: String, val lastChapterIndex: Int?, @@ -36,7 +57,15 @@ data class SharedFolderBookMetadata( val locatorBlockIndex: Int?, val locatorCharOffset: Int?, val customName: String?, - val highlightsJson: String? + val highlightsJson: String?, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null ) { fun toJsonString(): String { return folderSyncJson.encodeToString( @@ -44,8 +73,6 @@ data class SharedFolderBookMetadata( JsonObject( mapOf( "bookId" to JsonPrimitive(bookId), - "title" to title.asJson(), - "author" to author.asJson(), "displayName" to JsonPrimitive(displayName), "type" to JsonPrimitive(type), "lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1), @@ -76,6 +103,7 @@ data class SharedFolderBookMetadata( .takeIf { it.isNotEmpty() } val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis + val parsedReaderPosition = readerPositionOrNull() return (existing ?: BookItem( id = bookId, @@ -83,9 +111,9 @@ data class SharedFolderBookMetadata( type = parsedType, displayName = displayName.ifBlank { file.name }, timestamp = metadataTimestamp, - title = title ?: displayName.ifBlank { file.name }, - author = author, + title = file.name.substringBeforeLast('.', missingDelimiterValue = file.name), fileSize = file.size, + fileContentModifiedTimestamp = file.lastModified, sourceFolder = file.sourceFolder, isRecent = isRecent )).copy( @@ -95,19 +123,38 @@ data class SharedFolderBookMetadata( displayName = displayName.ifBlank { file.name }, timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp, coverImagePath = existing?.coverImagePath, - title = title ?: existing?.title ?: displayName.ifBlank { file.name }, - author = author ?: existing?.author, + title = existing?.title ?: file.name.substringBeforeLast('.', missingDelimiterValue = file.name), + author = existing?.author, + description = existing?.description, + originalTitle = existing?.originalTitle, + originalAuthor = existing?.originalAuthor, + originalSeriesName = existing?.originalSeriesName, + originalSeriesIndex = existing?.originalSeriesIndex, + originalDescription = existing?.originalDescription, progressPercentage = progressPercentage, isRecent = isRecent || (existing?.isRecent ?: false), fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L, + fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: existing?.fileContentModifiedTimestamp ?: 0L, sourceFolder = file.sourceFolder, folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, + seriesName = existing?.seriesName, + seriesIndex = existing?.seriesIndex, lastPageIndex = lastPage, + readerPosition = parsedReaderPosition ?: existing?.readerPosition, readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(), readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty() ) } + private fun readerPositionOrNull(): ReaderLocator? { + if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null + return ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = lastPage + ) + } + private fun parseReaderBookmarks(bookId: String): List { return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson) .mapIndexed { index, bookmark -> @@ -135,8 +182,8 @@ data class SharedFolderBookMetadata( val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null return SharedFolderBookMetadata( bookId = bookId, - title = obj.string("title"), - author = obj.string("author"), + title = null, + author = null, displayName = obj.string("displayName") ?: "Unknown", type = obj.string("type") ?: FileType.PDF.name, lastChapterIndex = obj.sentinelInt("lastChapterIndex"), @@ -149,7 +196,15 @@ data class SharedFolderBookMetadata( locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"), locatorCharOffset = obj.sentinelInt("locatorCharOffset"), customName = obj.string("customName"), - highlightsJson = obj.string("highlightsJson") + highlightsJson = obj.string("highlightsJson"), + seriesName = null, + seriesIndex = null, + description = null, + originalTitle = null, + originalAuthor = null, + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null ) } } @@ -286,7 +341,10 @@ object LocalFolderSyncEngine { } else { val updatedForFile = existing.withScannedFile(file) val updated = metadata - ?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() } + ?.takeIf { + it.lastModifiedTimestamp > 0L && + it.lastModifiedTimestamp >= updatedForFile.localFolderModifiedTimestamp() + } ?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis) ?: updatedForFile booksById[stableId] = updated @@ -348,19 +406,33 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { val highlightsJson = readerHighlights .takeIf { it.isNotEmpty() } ?.let(EpubAnnotationSerializer::highlightsToJson) - val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null - val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank() + val position = readerPosition + val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null || position != null + val isDirty = isRecent || + hasProgress || + !bookmarksJson.isNullOrBlank() || + !highlightsJson.isNullOrBlank() if (!isDirty) return null + val positionCfi = position?.cfi ?: position?.let { locator -> + val chapterIndex = locator.chapterIndex + val startOffset = locator.startOffset + val endOffset = locator.endOffset ?: startOffset + if (chapterIndex != null && startOffset != null && endOffset != null) { + "desktop:$chapterIndex:$startOffset:$endOffset" + } else { + null + } + } return SharedFolderBookMetadata( bookId = id, - title = title, - author = author, + title = null, + author = null, displayName = displayName, type = type.name, - lastChapterIndex = null, - lastPage = lastPageIndex, - lastPositionCfi = null, + lastChapterIndex = position?.chapterIndex, + lastPage = position?.pageIndex ?: lastPageIndex, + lastPositionCfi = positionCfi, progressPercentage = progressPercentage ?: 0f, isRecent = isRecent, lastModifiedTimestamp = localFolderModifiedTimestamp(), @@ -368,7 +440,15 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { locatorBlockIndex = null, locatorCharOffset = null, customName = null, - highlightsJson = highlightsJson + highlightsJson = highlightsJson, + seriesName = null, + seriesIndex = null, + description = null, + originalTitle = null, + originalAuthor = null, + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null ) } @@ -402,6 +482,7 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): timestamp = nowMillis, title = name.substringBeforeLast('.', missingDelimiterValue = name), fileSize = size, + fileContentModifiedTimestamp = lastModified, sourceFolder = sourceFolder, isRecent = false ) @@ -409,14 +490,28 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem { val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size + val modifiedChanged = file.lastModified > 0L && + file.lastModified != fileContentModifiedTimestamp + val contentChanged = sizeChanged || modifiedChanged return copy( path = file.path, type = file.type, displayName = file.name, - coverImagePath = if (sizeChanged) null else coverImagePath, + coverImagePath = if (contentChanged) null else coverImagePath, + title = if (contentChanged) file.name.substringBeforeLast('.', missingDelimiterValue = file.name) else title, + author = if (contentChanged) null else author, + description = if (contentChanged) null else description, + originalTitle = if (contentChanged) null else originalTitle, + originalAuthor = if (contentChanged) null else originalAuthor, + originalSeriesName = if (contentChanged) null else originalSeriesName, + originalSeriesIndex = if (contentChanged) null else originalSeriesIndex, + originalDescription = if (contentChanged) null else originalDescription, + seriesName = if (contentChanged) null else seriesName, + seriesIndex = if (contentChanged) null else seriesIndex, fileSize = file.size.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: fileContentModifiedTimestamp, sourceFolder = file.sourceFolder, - folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed + folderTextMetadataParsed = if (contentChanged) false else folderTextMetadataParsed ) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt index d8f34a4..2fb43be 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt @@ -53,11 +53,8 @@ object EpubAnnotationSerializer { fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? { if (rawJson.isNullOrBlank()) return null - parseHighlightJson(rawJson)?.let { return it } - val unwrapped = runCatching { - json.parseToJsonElement(rawJson).jsonPrimitive.content - }.getOrNull() - return parseHighlightJson(unwrapped) + val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return null + return element.asHighlightLenientOrNull() } fun highlightsToJson(highlights: Collection): String { @@ -202,6 +199,22 @@ object EpubAnnotationSerializer { ) } + private fun JsonElement.asHighlightLenientOrNull(): UserHighlight? { + return when (this) { + is JsonObject -> asHighlightOrNull() + is JsonArray -> { + for (element in this) { + element.asHighlightLenientOrNull()?.let { return it } + } + null + } + else -> contentOrNull() + ?.trim() + ?.takeIf { it.startsWith("{") || it.startsWith("[") } + ?.let { parseHighlightJsonLenient(it) } + } + } + private fun UserHighlight.toJsonObject(): JsonObject { return JsonObject( mapOf( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt index ae553b3..9c661d8 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt @@ -319,8 +319,18 @@ object ReaderTtsPlanner { } fun chunksFromCurrentLocation(session: ReaderSessionState): List { - val pageIndex = session.reader.currentPageIndex - return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0))) + val anchor = session.navigationLocator + val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex + val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) } + val chunks = chunksForPages(session.reader.book, pages) + val chapterIndex = anchor?.chapterIndex + val startOffset = anchor?.startOffset + if (chapterIndex == null && startOffset == null) return chunks + var nextIndex = 0 + return chunks.mapNotNull { chunk -> + chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset) + ?.copy(index = nextIndex++) + } } fun chunksForText( @@ -388,6 +398,35 @@ object ReaderTtsPlanner { } } + private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? { + if (chapterIndex != null) { + if (this.chapterIndex < chapterIndex) return null + if (this.chapterIndex > chapterIndex) return this + } + val anchorOffset = startOffset ?: return this + if (endOffset <= anchorOffset) return null + if (anchorOffset <= this.startOffset) return this + return trimStartTo(anchorOffset) + } + + private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? { + val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset) + if (boundedOffset <= startOffset) return this + if (boundedOffset >= endOffset) return null + val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length) + val remaining = text.drop(rawDrop) + val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() } + if (leadingWhitespace < 0) return null + val nextText = remaining.drop(leadingWhitespace) + if (nextText.isBlank()) return null + val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset) + return copy( + text = nextText, + spokenText = nextText, + startOffset = nextStartOffset + ) + } + private fun chunksForSemanticPages( chapter: SharedEpubChapter, pages: List diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt index 3cb82a4..22cec04 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt @@ -7,7 +7,8 @@ data class ImportedBookFile( val uriString: String?, val localPath: String?, val size: Long, - val sourceFolder: String? = null + val sourceFolder: String? = null, + val id: String? = null ) interface BookRepository { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt new file mode 100644 index 0000000..7143220 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt @@ -0,0 +1,987 @@ +package com.aryan.reader.shared + +enum class SharedSettingsPlatform { + ANDROID, + DESKTOP +} + +enum class SharedSettingsSection( + val title: String, + val summary: String +) { + READER( + title = "Reader settings", + summary = "Global defaults for text, EPUB, PDF, toolbar, and speech" + ), + APP_LIBRARY( + title = "App & library", + summary = "App preferences, imports, tabs, and local library behavior" + ), + SYNC_ACCOUNTS( + title = "Sync & accounts", + summary = "Sign-in, cloud sync, and folder backup" + ), + AI_TTS( + title = "AI & TTS", + summary = "Reader AI, keys, models, voices, and speech preferences" + ), + STORAGE_ADVANCED( + title = "Storage & advanced", + summary = "Caches and diagnostic tools" + ), + HELP( + title = "Help", + summary = "Feedback, support, and app information" + ), + EXTRA( + title = "Extra", + summary = "Overflow options, maintenance, and diagnostics" + ) +} + +enum class SharedSettingsDestination { + ROOT, + EPUB_TEXT, + PDF_COMICS, + THEME_APPEARANCE, + TTS_AI, + LIBRARY_SYNC_STORAGE, + SYNC_ACCOUNTS, + EXTRA, + HELP_ABOUT, + EPUB_FORMAT, + EPUB_THEME_TEXTURE, + EPUB_VISUAL_DEFAULTS, + PDF_APPEARANCE_DEFAULTS, + PDF_READER_TOOLS, + READER_TOOLBAR_DEFAULTS, + EPUB_TTS_REPLACEMENTS, + GLOBAL_TTS_REPLACEMENTS +} + +fun SharedSettingsDestination.parentDestination(): SharedSettingsDestination? { + return when (this) { + SharedSettingsDestination.ROOT -> null + SharedSettingsDestination.EPUB_TEXT, + SharedSettingsDestination.PDF_COMICS, + SharedSettingsDestination.THEME_APPEARANCE, + SharedSettingsDestination.TTS_AI, + SharedSettingsDestination.LIBRARY_SYNC_STORAGE, + SharedSettingsDestination.SYNC_ACCOUNTS, + SharedSettingsDestination.EXTRA, + SharedSettingsDestination.HELP_ABOUT -> SharedSettingsDestination.ROOT + SharedSettingsDestination.EPUB_FORMAT, + SharedSettingsDestination.EPUB_THEME_TEXTURE, + SharedSettingsDestination.EPUB_VISUAL_DEFAULTS, + SharedSettingsDestination.READER_TOOLBAR_DEFAULTS, + SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> SharedSettingsDestination.EPUB_TEXT + SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS, + SharedSettingsDestination.PDF_READER_TOOLS -> SharedSettingsDestination.PDF_COMICS + SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> SharedSettingsDestination.TTS_AI + } +} + +enum class SharedSettingsPageKind { + ROOT, + CATEGORY, + DETAIL +} + +data class SharedSettingsCategoryModel( + val destination: SharedSettingsDestination, + val title: String, + val summary: String, + val itemCount: Int +) { + fun matches(query: String): Boolean { + val normalized = query.trim() + if (normalized.isBlank()) return true + return title.contains(normalized, ignoreCase = true) || + summary.contains(normalized, ignoreCase = true) || + destination.name.contains(normalized, ignoreCase = true) + } +} + +data class SharedSettingsPageModel( + val destination: SharedSettingsDestination, + val title: String, + val summary: String, + val kind: SharedSettingsPageKind, + val parent: SharedSettingsDestination?, + val categories: List = emptyList(), + val items: List = emptyList(), + val localOverrideNote: SharedSettingsItemModel? = null +) + +data class SharedSettingsSearchResult( + val title: String, + val summary: String, + val breadcrumb: String, + val destination: SharedSettingsDestination? = null, + val action: SharedSettingsAction? = null, + val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION, + val enabled: Boolean = true, + val checked: Boolean? = null +) + +enum class SharedSettingsItemKind { + CONTROL, + NAVIGATION, + TOGGLE, + DESTRUCTIVE, + INFO +} + +enum class SharedSettingsAction { + TEXT_READER_DEFAULTS, + PDF_READER_DEFAULTS, + READER_TOOLBAR, + TTS_REPLACEMENTS, + LOCAL_OVERRIDE_NOTE, + APP_THEME, + LANGUAGE, + TABS_TOGGLE, + RECENT_LIMIT, + STRICT_FILE_FILTER, + EXTERNAL_FILE_BEHAVIOR, + SCREEN_CAPTURE_PROTECTION, + CUSTOM_FONTS, + SIGN_IN, + SIGN_OUT, + CLOUD_SYNC, + FOLDER_SYNC, + DEVICE_MANAGEMENT, + AI_SETTINGS, + HIDE_READER_AI, + TTS_SETTINGS, + CLEAR_BOOK_CACHE, + CLEAR_REFLOW_CACHE, + CLEAR_CLOUD_LOCAL_DATA, + TEST_PANEL_DETECTION, + TEST_SPEECH_BUBBLE_DETECTION, + EXPORT_LOGS, + DEBUG_ACTIONS, + HELP_FEEDBACK, + SUPPORT, + ABOUT +} + +data class SharedSettingsItemModel( + val action: SharedSettingsAction, + val title: String, + val summary: String, + val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION, + val enabled: Boolean = true, + val checked: Boolean? = null, + val destination: SharedSettingsDestination? = null +) { + fun matches(query: String): Boolean { + val normalized = query.trim() + if (normalized.isBlank()) return true + return title.contains(normalized, ignoreCase = true) || + summary.contains(normalized, ignoreCase = true) || + action.name.contains(normalized, ignoreCase = true) + } +} + +data class SharedSettingsSectionModel( + val section: SharedSettingsSection, + val items: List +) { + fun matches(query: String): Boolean { + val normalized = query.trim() + if (normalized.isBlank()) return true + return section.title.contains(normalized, ignoreCase = true) || + section.summary.contains(normalized, ignoreCase = true) + } +} + +data class SharedSettingsHubModel( + val platform: SharedSettingsPlatform, + val sections: List +) { + val rootCategories: List + get() = buildRootCategories() + + fun page(destination: SharedSettingsDestination): SharedSettingsPageModel { + return when (destination) { + SharedSettingsDestination.ROOT -> SharedSettingsPageModel( + destination = SharedSettingsDestination.ROOT, + title = "Settings", + summary = "Global defaults, app preferences, and advanced options", + kind = SharedSettingsPageKind.ROOT, + parent = null, + categories = rootCategories + ) + SharedSettingsDestination.EPUB_TEXT -> categoryPage( + destination = destination, + title = "EPUB & Text", + summary = "Defaults for reflowable reading, layout, EPUB themes, and reader tools", + items = epubAndTextItems() + ) + SharedSettingsDestination.PDF_COMICS -> categoryPage( + destination = destination, + title = "PDF & Comics", + summary = "Defaults for fixed-layout reading, PDF themes, and PDF-specific tools", + items = pdfAndComicItems() + ) + SharedSettingsDestination.THEME_APPEARANCE -> categoryPage( + destination = destination, + title = "App Preferences", + summary = "App theme and general app behavior", + items = themeAndAppearanceItems() + ) + SharedSettingsDestination.TTS_AI -> categoryPage( + destination = destination, + title = ttsAiTitle(), + summary = ttsAiSummary(), + items = ttsAndAiItems() + ) + SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> categoryPage( + destination = destination, + title = "Library & Files", + summary = "Recent files and local reading fonts", + items = libraryAndFileItems() + ) + SharedSettingsDestination.SYNC_ACCOUNTS -> categoryPage( + destination = destination, + title = "Sync & Accounts", + summary = "Sign-in, cloud sync, folder sync, and devices", + items = syncAndAccountItems() + ) + SharedSettingsDestination.EXTRA -> categoryPage( + destination = destination, + title = "Extra", + summary = "More-menu options, maintenance actions, diagnostics, and app info", + items = extraItems() + ) + SharedSettingsDestination.HELP_ABOUT -> categoryPage( + destination = destination, + title = "Help & About", + summary = "Feedback, support, project information, and licenses", + items = helpAndAboutItems() + ) + SharedSettingsDestination.EPUB_FORMAT -> detailPage( + destination = destination, + title = "Format Defaults", + summary = "Font, size, spacing, margins, alignment, and reading mode", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.EPUB_THEME_TEXTURE -> detailPage( + destination = destination, + title = "EPUB Theme & Texture", + summary = "Default EPUB reading theme, paper texture, and texture strength", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> detailPage( + destination = destination, + title = "Visual Defaults", + summary = "Page indicators, system UI, images, and chapter-turn behavior", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> detailPage( + destination = destination, + title = "PDF Theme Defaults", + summary = "Default PDF and comic theme where fixed-layout appearance is supported", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.PDF_READER_TOOLS -> detailPage( + destination = destination, + title = "PDF Reader Tools", + summary = "Auto-scroll, OCR, annotation, and PDF-only tools remain in the PDF reader", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> detailPage( + destination = destination, + title = "Reader Toolbar Defaults", + summary = "Visible tools, bottom-bar actions, and reader overflow tools", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> detailPage( + destination = destination, + title = "Global TTS Replacements", + summary = "Words and phrases replaced only during speech playback", + localOverrideNote = localOverrideItem() + ) + SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> detailPage( + destination = destination, + title = "Global TTS Replacements", + summary = "Words and phrases replaced only during speech playback", + localOverrideNote = localOverrideItem() + ) + } + } + + fun searchResults(query: String): List { + val normalized = query.trim() + if (normalized.isBlank()) return emptyList() + + val categoryResults = rootCategories + .filter { it.matches(normalized) } + .map { category -> + SharedSettingsSearchResult( + title = category.title, + summary = category.summary, + breadcrumb = "Settings", + destination = category.destination + ) + } + + val itemResults = listOf( + SharedSettingsDestination.EPUB_TEXT, + SharedSettingsDestination.PDF_COMICS, + SharedSettingsDestination.THEME_APPEARANCE, + SharedSettingsDestination.TTS_AI, + SharedSettingsDestination.LIBRARY_SYNC_STORAGE, + SharedSettingsDestination.SYNC_ACCOUNTS, + SharedSettingsDestination.EXTRA + ).flatMap { destination -> + val page = page(destination) + page.items + .filter { it.matches(normalized) } + .map { item -> + SharedSettingsSearchResult( + title = item.title, + summary = item.summary, + breadcrumb = "Settings / ${page.title}", + destination = item.destination, + action = item.action, + kind = item.kind, + enabled = item.enabled, + checked = item.checked + ) + } + } + + return (categoryResults + itemResults) + .distinctBy { result -> result.searchIdentity() } + } + + fun filtered(query: String): SharedSettingsHubModel { + val normalized = query.trim() + if (normalized.isBlank()) return this + return copy( + sections = sections.mapNotNull { section -> + val matchingItems = section.items.filter { it.matches(normalized) } + when { + matchingItems.isNotEmpty() -> section.copy(items = matchingItems) + section.matches(normalized) -> section + else -> null + } + } + ) + } + + fun itemsIn(section: SharedSettingsSection): List { + return sections.firstOrNull { it.section == section }?.items.orEmpty() + } + + private fun buildRootCategories(): List { + return listOf( + rootCategory( + destination = SharedSettingsDestination.EPUB_TEXT, + title = "EPUB & Text", + summary = "Format, EPUB theme, visual defaults, and reader tools", + itemCount = epubAndTextItems().size + ), + rootCategory( + destination = SharedSettingsDestination.PDF_COMICS, + title = "PDF & Comics", + summary = "Separate PDF theme and fixed-layout reader defaults", + itemCount = pdfAndComicItems().size + ), + rootCategory( + destination = SharedSettingsDestination.THEME_APPEARANCE, + title = "App Preferences", + summary = "App theme and general app behavior", + itemCount = themeAndAppearanceItems().size + ), + rootCategory( + destination = SharedSettingsDestination.TTS_AI, + title = ttsAiTitle(), + summary = ttsAiSummary(), + itemCount = ttsAndAiItems().size + ), + rootCategory( + destination = SharedSettingsDestination.LIBRARY_SYNC_STORAGE, + title = "Library & Files", + summary = "Recent files and local reading fonts", + itemCount = libraryAndFileItems().size + ), + rootCategory( + destination = SharedSettingsDestination.SYNC_ACCOUNTS, + title = "Sync & Accounts", + summary = "Sign-in, cloud sync, folder sync, and devices", + itemCount = syncAndAccountItems().size + ), + rootCategory( + destination = SharedSettingsDestination.EXTRA, + title = "Extra", + summary = "More-menu options, maintenance, diagnostics, and app info", + itemCount = extraItems().size + ) + ).filter { it.itemCount > 0 } + } + + private fun rootCategory( + destination: SharedSettingsDestination, + title: String, + summary: String, + itemCount: Int + ): SharedSettingsCategoryModel { + return SharedSettingsCategoryModel( + destination = destination, + title = title, + summary = summary, + itemCount = itemCount + ) + } + + private fun ttsAiTitle(): String { + return if (hasAiSettingsItem()) "TTS & AI" else "TTS" + } + + private fun ttsAiSummary(): String { + return if (hasAiSettingsItem()) { + "Global voice, speech replacements, keys, and reader AI" + } else { + "Global voice, speech behavior, and TTS replacements" + } + } + + private fun hasAiSettingsItem(): Boolean { + return baseItem(SharedSettingsAction.AI_SETTINGS) != null + } + + private fun categoryPage( + destination: SharedSettingsDestination, + title: String, + summary: String, + items: List + ): SharedSettingsPageModel { + return SharedSettingsPageModel( + destination = destination, + title = title, + summary = summary, + kind = SharedSettingsPageKind.CATEGORY, + parent = destination.parentDestination(), + items = items + ) + } + + private fun detailPage( + destination: SharedSettingsDestination, + title: String, + summary: String, + localOverrideNote: SharedSettingsItemModel? + ): SharedSettingsPageModel { + return SharedSettingsPageModel( + destination = destination, + title = title, + summary = summary, + kind = SharedSettingsPageKind.DETAIL, + parent = destination.parentDestination(), + localOverrideNote = localOverrideNote + ) + } + + private fun epubAndTextItems(): List { + return buildList { + baseItem(SharedSettingsAction.TEXT_READER_DEFAULTS)?.let { item -> + add( + item.destinationRow( + destination = SharedSettingsDestination.EPUB_FORMAT, + title = "Format defaults", + summary = "Font, size, line spacing, margins, alignment, and reading mode" + ) + ) + add( + item.destinationRow( + destination = SharedSettingsDestination.EPUB_THEME_TEXTURE, + title = "Theme and texture", + summary = "Reading theme, texture, and page feel for new books" + ) + ) + add( + item.destinationRow( + destination = SharedSettingsDestination.EPUB_VISUAL_DEFAULTS, + title = "Visual defaults", + summary = "System UI, page info, images, and chapter-turn behavior" + ) + ) + } + baseItem(SharedSettingsAction.READER_TOOLBAR)?.let { item -> + add(item.destinationRow(SharedSettingsDestination.READER_TOOLBAR_DEFAULTS)) + } + } + } + + private fun pdfAndComicItems(): List { + return buildList { + baseItem(SharedSettingsAction.PDF_READER_DEFAULTS)?.let { item -> + add( + item.destinationRow( + destination = SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS, + title = "PDF theme defaults", + summary = "PDF and comic theme defaults, separate from EPUB themes" + ) + ) + add( + item.destinationRow( + destination = SharedSettingsDestination.PDF_READER_TOOLS, + title = "PDF reader tools", + summary = "Auto-scroll, OCR, annotations, and PDF-only tools" + ) + ) + } + } + } + + private fun themeAndAppearanceItems(): List { + return itemsForActions( + SharedSettingsAction.APP_THEME + ) + } + + private fun ttsAndAiItems(): List { + return buildList { + addAll( + itemsForActions( + SharedSettingsAction.TTS_SETTINGS, + SharedSettingsAction.AI_SETTINGS + ) + ) + baseItem(SharedSettingsAction.TTS_REPLACEMENTS)?.let { item -> + add(item.destinationRow(SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS)) + } + } + } + + private fun libraryAndFileItems(): List { + return itemsForActions( + SharedSettingsAction.RECENT_LIMIT, + SharedSettingsAction.CUSTOM_FONTS + ) + } + + private fun syncAndAccountItems(): List { + return itemsForActions( + SharedSettingsAction.SIGN_IN, + SharedSettingsAction.SIGN_OUT, + SharedSettingsAction.CLOUD_SYNC, + SharedSettingsAction.FOLDER_SYNC + ) + } + + private fun extraItems(): List { + return itemsForActions( + SharedSettingsAction.LANGUAGE, + SharedSettingsAction.SCREEN_CAPTURE_PROTECTION, + SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, + SharedSettingsAction.STRICT_FILE_FILTER, + SharedSettingsAction.TABS_TOGGLE, + SharedSettingsAction.HIDE_READER_AI, + SharedSettingsAction.CLEAR_BOOK_CACHE, + SharedSettingsAction.CLEAR_REFLOW_CACHE, + SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, + SharedSettingsAction.TEST_PANEL_DETECTION, + SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, + SharedSettingsAction.EXPORT_LOGS, + SharedSettingsAction.DEVICE_MANAGEMENT, + SharedSettingsAction.HELP_FEEDBACK, + SharedSettingsAction.SUPPORT, + SharedSettingsAction.ABOUT + ) + } + + private fun helpAndAboutItems(): List { + return itemsForActions( + SharedSettingsAction.HELP_FEEDBACK, + SharedSettingsAction.SUPPORT, + SharedSettingsAction.ABOUT + ) + } + + private fun itemsForActions(vararg actions: SharedSettingsAction): List { + return actions.mapNotNull(::baseItem) + } + + private fun localOverrideItem(): SharedSettingsItemModel? { + return baseItem(SharedSettingsAction.LOCAL_OVERRIDE_NOTE) + } + + private fun baseItem(action: SharedSettingsAction): SharedSettingsItemModel? { + return sections.asSequence() + .flatMap { it.items.asSequence() } + .firstOrNull { it.action == action } + } + + private fun SharedSettingsItemModel.destinationRow( + destination: SharedSettingsDestination, + title: String = this.title, + summary: String = this.summary + ): SharedSettingsItemModel { + return copy( + title = title, + summary = summary, + kind = SharedSettingsItemKind.NAVIGATION, + destination = destination + ) + } +} + +private fun SharedSettingsSearchResult.searchIdentity(): String { + return when (action) { + SharedSettingsAction.TTS_REPLACEMENTS -> SharedSettingsAction.TTS_REPLACEMENTS.name + else -> destination?.name ?: action?.name ?: title + } +} + +data class SharedSettingsHubInput( + val platform: SharedSettingsPlatform, + val featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, + val isDebugBuild: Boolean = false, + val isSignedIn: Boolean = false, + val isProUser: Boolean = false, + val syncAvailable: Boolean = true, + val folderSyncAvailable: Boolean = true, + val aiSettingsAvailable: Boolean = true, + val ttsSettingsAvailable: Boolean = true, + val includePdfReaderDefaults: Boolean = true, + val includeReaderToolbar: Boolean = true, + val includeLanguage: Boolean = true, + val includeScreenCaptureProtection: Boolean = false, + val includeExternalFileBehavior: Boolean = true, + val includeRecentLimit: Boolean = true, + val includeCustomFonts: Boolean = true, + val includeStrictFileFilter: Boolean = true, + val includeReaderTabs: Boolean = true, + val includeHideReaderAi: Boolean = true, + val includeCloudLocalDataClear: Boolean = false, + val supportProjectAvailable: Boolean = true, + val isTabsEnabled: Boolean = true, + val isSyncEnabled: Boolean = false, + val isFolderSyncEnabled: Boolean = false, + val useStrictFileFilter: Boolean = false, + val isScreenCaptureProtectionEnabled: Boolean = false, + val hideReaderAi: Boolean = false +) + +fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubModel { + val sections = listOf( + SharedSettingsSectionModel( + section = SharedSettingsSection.READER, + items = buildList { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TEXT_READER_DEFAULTS, + title = "Text and EPUB defaults", + summary = "Format, EPUB theme, texture, visual behavior, and text layout", + kind = SharedSettingsItemKind.CONTROL + ) + ) + if (input.includePdfReaderDefaults) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.PDF_READER_DEFAULTS, + title = "PDF and comic defaults", + summary = "PDF theme, visual defaults, tools, auto-scroll, OCR, and annotation behavior where available", + kind = SharedSettingsItemKind.NAVIGATION + ) + ) + } + if (input.includeReaderToolbar) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.READER_TOOLBAR, + title = "Reader toolbar and tools", + summary = "Choose visible tools, bottom-bar tools, and reader overflow tools", + kind = SharedSettingsItemKind.CONTROL + ) + ) + } + add( + SharedSettingsItemModel( + action = SharedSettingsAction.LOCAL_OVERRIDE_NOTE, + title = "Per-book overrides", + summary = "Local overrides are available from the active reader screen and still win for that book.", + kind = SharedSettingsItemKind.INFO + ) + ) + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.APP_LIBRARY, + items = buildList { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.APP_THEME, + title = "App theme", + summary = "Theme mode, contrast, reading text dimming, and custom app colors" + ) + ) + if (input.includeCustomFonts) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.CUSTOM_FONTS, + title = "Custom fonts", + summary = "Import, manage, and reuse local reading fonts" + ) + ) + } + if (input.includeRecentLimit) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.RECENT_LIMIT, + title = "Recent files limit", + summary = "Control how many recent books appear on Home" + ) + ) + } + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.SYNC_ACCOUNTS, + items = buildList { + if (input.syncAvailable && input.featurePolicy.aiAndCloud) { + if (input.isSignedIn) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.SIGN_OUT, + title = "Sign out", + summary = "Disconnect this device from your account", + kind = SharedSettingsItemKind.DESTRUCTIVE + ) + ) + } else { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.SIGN_IN, + title = "Sign in", + summary = "Connect sync and account features" + ) + ) + } + add( + SharedSettingsItemModel( + action = SharedSettingsAction.CLOUD_SYNC, + title = "Cloud library sync", + summary = if (input.isProUser) "Sync library metadata across signed-in devices." else "A Pro account is required for cloud sync.", + kind = SharedSettingsItemKind.TOGGLE, + enabled = input.isProUser, + checked = input.isSyncEnabled + ) + ) + } + if (input.folderSyncAvailable) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.FOLDER_SYNC, + title = "Folder backup and sync", + summary = "Keep selected local folders represented in the library", + kind = SharedSettingsItemKind.TOGGLE, + checked = input.isFolderSyncEnabled + ) + ) + } + if (input.isDebugBuild && input.featurePolicy.aiAndCloud && input.syncAvailable) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.DEVICE_MANAGEMENT, + title = "Device management", + summary = "Inspect registered devices for this account" + ) + ) + } + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.AI_TTS, + items = buildList { + if (input.aiSettingsAvailable && input.featurePolicy.aiAndCloud) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.AI_SETTINGS, + title = "AI keys and models", + summary = "Configure reader AI and cloud TTS model access" + ) + ) + } + if (input.ttsSettingsAvailable) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TTS_SETTINGS, + title = "TTS voice settings", + summary = "Choose cloud or device voices and speech behavior" + ) + ) + } + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TTS_REPLACEMENTS, + title = "Global TTS replacements", + summary = "Words and phrases replaced only during speech playback", + kind = SharedSettingsItemKind.CONTROL + ) + ) + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.STORAGE_ADVANCED, + items = buildList { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.CLEAR_BOOK_CACHE, + title = "Clear book cache", + summary = "Remove generated book cache files and recreate them on demand", + kind = SharedSettingsItemKind.DESTRUCTIVE + ) + ) + add( + SharedSettingsItemModel( + action = SharedSettingsAction.CLEAR_REFLOW_CACHE, + title = "Clear reflow cache", + summary = "Remove generated PDF text-view files", + kind = SharedSettingsItemKind.DESTRUCTIVE + ) + ) + if (input.isDebugBuild) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TEST_PANEL_DETECTION, + title = "Test panel detection", + summary = "Run the local panel-detection diagnostic" + ) + ) + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, + title = "Test speech-bubble detection", + summary = "Run the local speech-bubble detection diagnostic" + ) + ) + add( + SharedSettingsItemModel( + action = SharedSettingsAction.EXPORT_LOGS, + title = "Export logs", + summary = "Export recent diagnostic logs", + kind = SharedSettingsItemKind.NAVIGATION + ) + ) + if (input.includeCloudLocalDataClear && input.featurePolicy.aiAndCloud) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, + title = "Clear cloud and local data", + summary = "Delete cloud records and matching local library data", + kind = SharedSettingsItemKind.DESTRUCTIVE + ) + ) + } + } + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.EXTRA, + items = buildList { + if (input.includeLanguage) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.LANGUAGE, + title = "Language", + summary = "Choose the app language" + ) + ) + } + if (input.includeScreenCaptureProtection) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.SCREEN_CAPTURE_PROTECTION, + title = "Screen capture protection", + summary = "Block screenshots and screen recording on sensitive reader screens", + kind = SharedSettingsItemKind.TOGGLE, + checked = input.isScreenCaptureProtectionEnabled + ) + ) + } + if (input.includeExternalFileBehavior) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, + title = "External file behavior", + summary = "Choose whether external opens are copied into the app library" + ) + ) + } + if (input.includeStrictFileFilter) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.STRICT_FILE_FILTER, + title = "Strict file filter", + summary = "Use only known reader file types in import pickers", + kind = SharedSettingsItemKind.TOGGLE, + checked = input.useStrictFileFilter + ) + ) + } + if (input.includeReaderTabs) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.TABS_TOGGLE, + title = "Reader tabs", + summary = if (input.isTabsEnabled) "Opening PDFs keeps active tabs." else "PDFs replace the active reader session.", + kind = SharedSettingsItemKind.TOGGLE, + checked = input.isTabsEnabled + ) + ) + } + if (input.includeHideReaderAi && input.featurePolicy.aiAndCloud) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.HIDE_READER_AI, + title = "Reader AI visibility", + summary = if (input.hideReaderAi) "Reader AI tools are hidden." else "Reader AI tools are shown where available.", + kind = SharedSettingsItemKind.TOGGLE, + checked = !input.hideReaderAi + ) + ) + } + } + ), + SharedSettingsSectionModel( + section = SharedSettingsSection.HELP, + items = buildList { + if (input.featurePolicy.projectLinks) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.HELP_FEEDBACK, + title = "Help and feedback", + summary = "Send feedback or report an issue" + ) + ) + if (input.supportProjectAvailable) { + add( + SharedSettingsItemModel( + action = SharedSettingsAction.SUPPORT, + title = "Support project", + summary = "Open support options for the project" + ) + ) + } + } + add( + SharedSettingsItemModel( + action = SharedSettingsAction.ABOUT, + title = "About", + summary = "Version, source, licenses, and project information" + ) + ) + } + ) + ).filter { it.items.isNotEmpty() } + + return SharedSettingsHubModel( + platform = input.platform, + sections = sections + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt new file mode 100644 index 0000000..d4f3e80 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt @@ -0,0 +1,22 @@ +package com.aryan.reader.shared + +data class SharedFeaturePolicy( + val networkAccess: Boolean = true, + val opdsCatalogs: Boolean = networkAccess, + val aiAndCloud: Boolean = networkAccess, + val externalLookup: Boolean = networkAccess, + val projectLinks: Boolean = networkAccess, + val googleFontsDownload: Boolean = networkAccess +) { + companion object { + val Standard = SharedFeaturePolicy() + val OssOffline = SharedFeaturePolicy( + networkAccess = false, + opdsCatalogs = false, + aiAndCloud = false, + externalLookup = false, + projectLinks = false, + googleFontsDownload = false + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt index 719efdb..0dcbb49 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt @@ -9,13 +9,17 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderPageSpreadMode import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.SharedReaderTextAlign @@ -28,7 +32,7 @@ data class SharedLibrarySnapshot( val customFonts: List = emptyList(), val syncedFolders: List = emptyList(), val recentFilesLimit: Int = 12, - val isTabsEnabled: Boolean = false, + val isTabsEnabled: Boolean = true, val openTabIds: List = emptyList(), val activeTabBookId: String? = null, val pinnedHomeBookIds: Set = emptySet(), @@ -40,13 +44,16 @@ data class SharedLibrarySnapshot( val appTextDimFactorDark: Float = 1.0f, val appSeedColor: Color? = null, val customAppThemes: List = emptyList(), + val readerDefaultSettings: ReaderSettings = ReaderSettings(), + val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences() ) object SharedLibrarySnapshotJson { - private const val SCHEMA_VERSION = 10 + private const val SCHEMA_VERSION = 19 private val json = Json { prettyPrint = true @@ -60,6 +67,10 @@ object SharedLibrarySnapshotJson { val schemaVersion = root.int("schemaVersion", 1) val openTabIds = root.stringArray("openTabIds") + val readerDefaultSettings = root["readerDefaultSettings"] + ?.takeUnless { it is JsonNull } + ?.asReaderSettingsOrNull() + ?: ReaderSettings() return SharedLibrarySnapshot( books = root.array("books") .mapNotNull { it.asBookItemOrNull() } @@ -70,7 +81,7 @@ object SharedLibrarySnapshotJson { customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() }, syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() }, recentFilesLimit = root.int("recentFilesLimit", 12), - isTabsEnabled = root.boolean("isTabsEnabled", false), + isTabsEnabled = root.boolean("isTabsEnabled", true), openTabIds = openTabIds, activeTabBookId = root.string("activeTabBookId"), pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(), @@ -90,6 +101,11 @@ object SharedLibrarySnapshotJson { ?: 1.0f, appSeedColor = root.int("appSeedColor")?.let { Color(it) }, customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() }, + readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion), + pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"] + ?.takeUnless { it is JsonNull } + ?.asReaderSettingsOrNull() + ?: ReaderSettings(themeId = "no_theme"), readerToolbarPreferences = root["readerToolbarPreferences"] ?.takeUnless { it is JsonNull } ?.asReaderToolbarPreferencesOrNull() @@ -98,6 +114,10 @@ object SharedLibrarySnapshotJson { ?.takeUnless { it is JsonNull } ?.asReaderHighlightPaletteOrNull() ?: ReaderHighlightPalette(), + pdfHighlighterPalette = root["pdfHighlighterPalette"] + ?.takeUnless { it is JsonNull } + ?.asSharedPdfHighlighterPaletteOrNull() + ?: SharedPdfHighlighterPalette(), readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"] ?.takeUnless { it is JsonNull } ?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) } @@ -128,8 +148,11 @@ object SharedLibrarySnapshotJson { "appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark), "appSeedColor" to snapshot.appSeedColor.asJson(), "customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }), + "readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(), + "pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(), "readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(), "readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(), + "pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(), "readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement( snapshot.readerTtsReplacementPreferences, ) @@ -149,6 +172,12 @@ private fun JsonObject.stringArray(name: String): List { } } +private fun JsonObject.intArray(name: String): List { + return array(name).mapNotNull { element -> + runCatching { element.jsonPrimitive.intOrNull }.getOrNull() + } +} + private fun JsonObject.string(name: String): String? { return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull() } @@ -196,10 +225,18 @@ private fun List.migrateLegacyRecentState(schemaVersion: Int, openTabI private fun BookItem.hasReaderFootprint(openedBookIds: Set): Boolean { return id in openedBookIds || lastPageIndex != null || + readerPosition != null || (progressPercentage ?: 0f) > 0f || readerSettings != null || readerBookmarks.isNotEmpty() || - readerHighlights.isNotEmpty() + readerHighlights.isNotEmpty() || + pdfReaderViewport != null +} + +private fun ReaderSettings.migrateLegacyDefaultReadingMode(schemaVersion: Int): ReaderSettings { + if (schemaVersion >= 17 || readingMode != ReaderReadingMode.PAGINATED) return this + val oldDefaultSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED) + return if (this == oldDefaultSettings) copy(readingMode = ReaderReadingMode.VERTICAL) else this } private fun JsonElement.asBookItemOrNull(): BookItem? { @@ -216,18 +253,27 @@ private fun JsonElement.asBookItemOrNull(): BookItem? { coverImagePath = obj.string("coverImagePath"), title = obj.string("title"), author = obj.string("author"), + description = obj.string("description"), + originalTitle = obj.string("originalTitle"), + originalAuthor = obj.string("originalAuthor"), + originalSeriesName = obj.string("originalSeriesName"), + originalSeriesIndex = obj.double("originalSeriesIndex"), + originalDescription = obj.string("originalDescription"), progressPercentage = obj.float("progressPercentage"), isRecent = obj.boolean("isRecent", true), fileSize = obj.long("fileSize"), + fileContentModifiedTimestamp = obj.long("fileContentModifiedTimestamp"), sourceFolder = obj.string("sourceFolder"), folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false), seriesName = obj.string("seriesName"), seriesIndex = obj.double("seriesIndex"), tags = obj.array("tags").mapNotNull { it.asTagOrNull() }, lastPageIndex = obj.int("lastPageIndex"), + readerPosition = obj["readerPosition"]?.takeUnless { it is JsonNull }?.asReaderLocatorOrNull(), readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(), readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() }, - readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() } + readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }, + pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull() ) } @@ -282,8 +328,9 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? { lastScanTime = obj.long("lastScanTime"), allowedFileTypes = obj.stringArray("allowedFileTypes") .mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() } + .filter { it in SharedFileCapabilities.knownFileTypes } .toSet() - .ifEmpty { FileType.entries.toSet() } + .ifEmpty { SharedFileCapabilities.knownFileTypes } ) } @@ -307,18 +354,27 @@ private fun BookItem.toJsonObject(): JsonObject { "coverImagePath" to coverImagePath.asJson(), "title" to title.asJson(), "author" to author.asJson(), + "description" to description.asJson(), + "originalTitle" to originalTitle.asJson(), + "originalAuthor" to originalAuthor.asJson(), + "originalSeriesName" to originalSeriesName.asJson(), + "originalSeriesIndex" to originalSeriesIndex.asJson(), + "originalDescription" to originalDescription.asJson(), "progressPercentage" to progressPercentage.asJson(), "isRecent" to JsonPrimitive(isRecent), "fileSize" to JsonPrimitive(fileSize), + "fileContentModifiedTimestamp" to JsonPrimitive(fileContentModifiedTimestamp), "sourceFolder" to sourceFolder.asJson(), "folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed), "seriesName" to seriesName.asJson(), "seriesIndex" to seriesIndex.asJson(), "tags" to JsonArray(tags.map { it.toJsonObject() }), "lastPageIndex" to lastPageIndex.asJson(), + "readerPosition" to readerPosition.asJson(), "readerSettings" to readerSettings.asJson(), "readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }), - "readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }) + "readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }), + "pdfReaderViewport" to pdfReaderViewport.asJson() ) ) } @@ -374,7 +430,11 @@ private fun SyncedFolder.toJsonObject(): JsonObject { "uriString" to JsonPrimitive(uriString), "name" to JsonPrimitive(name), "lastScanTime" to JsonPrimitive(lastScanTime), - "allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray() + "allowedFileTypes" to allowedFileTypes + .filter { it in SharedFileCapabilities.knownFileTypes } + .map { it.name } + .sorted() + .asJsonArray() ) ) } @@ -400,6 +460,10 @@ private fun List.asJsonArray(): JsonArray { return JsonArray(map { JsonPrimitive(it) }) } +private fun List.asIntJsonArray(): JsonArray { + return JsonArray(map { JsonPrimitive(it) }) +} + private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? { val obj = runCatching { jsonObject }.getOrNull() ?: return null val defaults = ReaderSettings() @@ -435,6 +499,17 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? { pageInfoPosition = obj.string("pageInfoPosition") ?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() } ?: defaults.pageInfoPosition, + pageSpreadMode = obj.string("pageSpreadMode") + ?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() } + ?: defaults.pageSpreadMode, + pdfVerticalPageGapVisible = obj.boolean( + "pdfVerticalPageGapVisible", + defaults.pdfVerticalPageGapVisible + ), + pdfPageNumberOverlayVisible = obj.boolean( + "pdfPageNumberOverlayVisible", + defaults.pdfPageNumberOverlayVisible + ), seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation), chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier ) @@ -462,6 +537,29 @@ private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette return ReaderHighlightPalette(colors = colors).sanitized() } +private fun JsonElement.asSharedPdfHighlighterPaletteOrNull(): SharedPdfHighlighterPalette? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return SharedPdfHighlighterPalette(colors = obj.intArray("colorsArgb")).sanitized() +} + +private fun JsonElement.asSharedPdfReaderViewportOrNull(): SharedPdfReaderViewport? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val defaults = SharedPdfReaderViewport() + return SharedPdfReaderViewport( + pageIndex = obj.int("pageIndex") ?: defaults.pageIndex, + displayMode = obj.string("displayMode") + ?.let { runCatching { PdfDisplayMode.valueOf(it) }.getOrNull() } + ?: defaults.displayMode, + zoom = obj.float("zoom") ?: defaults.zoom, + horizontalScrollOffset = obj.int("horizontalScrollOffset") ?: defaults.horizontalScrollOffset, + paginatedVerticalScrollOffset = obj.int("paginatedVerticalScrollOffset") + ?: defaults.paginatedVerticalScrollOffset, + verticalFirstPageIndex = obj.int("verticalFirstPageIndex") ?: defaults.verticalFirstPageIndex, + verticalFirstPageScrollOffset = obj.int("verticalFirstPageScrollOffset") + ?: defaults.verticalFirstPageScrollOffset + ) +} + private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? { val obj = runCatching { jsonObject }.getOrNull() ?: return null val pageIndex = obj.int("pageIndex") ?: return null @@ -551,6 +649,9 @@ private fun ReaderSettings?.asJson(): JsonElement { "systemUiMode" to JsonPrimitive(settings.systemUiMode.name), "pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name), "pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name), + "pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name), + "pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible), + "pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible), "seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation), "chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier) ) @@ -576,6 +677,29 @@ private fun ReaderHighlightPalette.toJsonObject(): JsonObject { ) } +private fun SharedPdfHighlighterPalette.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "colorsArgb" to sanitized().colors.asIntJsonArray() + ) + ) +} + +private fun SharedPdfReaderViewport?.asJson(): JsonElement { + val viewport = this ?: return JsonNull + return JsonObject( + mapOf( + "pageIndex" to JsonPrimitive(viewport.pageIndex), + "displayMode" to JsonPrimitive(viewport.displayMode.name), + "zoom" to JsonPrimitive(viewport.zoom), + "horizontalScrollOffset" to JsonPrimitive(viewport.horizontalScrollOffset), + "paginatedVerticalScrollOffset" to JsonPrimitive(viewport.paginatedVerticalScrollOffset), + "verticalFirstPageIndex" to JsonPrimitive(viewport.verticalFirstPageIndex), + "verticalFirstPageScrollOffset" to JsonPrimitive(viewport.verticalFirstPageScrollOffset) + ) + ) +} + private fun ReaderBookmark.toJsonObject(): JsonObject { return JsonObject( mapOf( @@ -616,3 +740,7 @@ private fun ReaderLocator.toJsonObject(): JsonObject { } ) } + +private fun ReaderLocator?.asJson(): JsonElement { + return this?.toJsonObject() ?: JsonNull +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt index bd885e7..b22aff6 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt @@ -16,6 +16,7 @@ fun LibraryState.reduce(action: LibraryAction): LibraryState { } copy(selectedBookIds = selected) } + is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds) LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet()) is LibraryAction.ShelfSelectionToggled -> this LibraryAction.ShelfSelectionCleared -> this @@ -37,6 +38,7 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta } copy(selectedBookIds = selected) } + is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds) LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet()) is LibraryAction.ShelfSelectionToggled -> { val selected = if (action.shelfId in selectedShelfIds) { @@ -52,6 +54,18 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta } } +fun SharedReaderScreenState.replaceBookSelectionWithVisibleBooks( + visibleBooks: Collection +): SharedReaderScreenState { + val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.id } + val action = if (visibleIds.isNotEmpty() && selectedBookIds.containsAll(visibleIds)) { + LibraryAction.SelectionCleared + } else { + LibraryAction.BookSelectionReplaced(visibleIds) + } + return reduce(action) +} + fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { return when (action) { is AppAction.BannerShown -> copy(bannerMessage = action.message) @@ -115,6 +129,12 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { pinnedLibraryBookIds + action.bookId } ) + is AppAction.ReaderDefaultSettingsChanged -> copy( + readerDefaultSettings = action.settings + ) + is AppAction.PdfReaderDefaultSettingsChanged -> copy( + pdfReaderDefaultSettings = action.settings + ) is AppAction.ReaderToolbarPreferencesChanged -> copy( readerToolbarPreferences = action.preferences.sanitized() ) @@ -130,6 +150,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { is AppAction.ReaderHighlightPaletteChanged -> copy( readerHighlightPalette = action.palette.sanitized() ) + is AppAction.PdfHighlighterPaletteChanged -> copy( + pdfHighlighterPalette = action.palette.sanitized() + ) is AppAction.ReaderTtsReplacementPreferencesChanged -> copy( readerTtsReplacementPreferences = action.preferences ) @@ -145,8 +168,18 @@ fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress) is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex) is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator) + is ReaderAction.JumpToPage -> readerEngine.jumpToPage(this, action.pageIndex) + is ReaderAction.JumpToPageNumber -> readerEngine.jumpToPageNumber(this, action.pageNumber) + is ReaderAction.JumpToChapter -> readerEngine.jumpToChapter(this, action.chapterIndex) + is ReaderAction.JumpToLocator -> readerEngine.jumpToLocator(this, action.locator) is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator) is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex) + is ReaderAction.JumpToSearchResult -> readerEngine.jumpToSearchResult(this, action.resultIndex) + ReaderAction.JumpToNextSearchResult -> readerEngine.jumpToNextSearchResult(this) + ReaderAction.JumpToPreviousSearchResult -> readerEngine.jumpToPreviousSearchResult(this) + ReaderAction.JumpBack -> readerEngine.jumpBack(this) + ReaderAction.JumpForward -> readerEngine.jumpForward(this) + ReaderAction.JumpHistoryCleared -> readerEngine.clearJumpHistory(this) is ReaderAction.SearchChanged -> readerEngine.search(this, action.query) ReaderAction.SearchOpened -> readerEngine.openSearch(this) ReaderAction.SearchClosed -> readerEngine.closeSearch(this) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt index 9149873..9eabf11 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt @@ -37,6 +37,8 @@ data class OpdsAcquisition( get() = when { mimeType.contains("epub", ignoreCase = true) -> "EPUB" mimeType.contains("pdf", ignoreCase = true) -> "PDF" + mimeType.contains("presentationml.presentation", ignoreCase = true) || + mimeType.contains("pptx", ignoreCase = true) -> "PPTX" mimeType.contains("markdown", ignoreCase = true) || mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD" mimeType.contains("html", ignoreCase = true) || @@ -58,6 +60,7 @@ data class OpdsAcquisition( get() = when (formatName) { "EPUB" -> 5 "PDF" -> 4 + "PPTX" -> 4 "MOBI" -> 3 "FB2", "MD", "HTML" -> 2 "CBZ", "CBR", "CB7" -> 1 diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt index 734603a..97bdc46 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt @@ -76,6 +76,7 @@ object SharedOpdsDownloadNamer { return when (acquisition.formatName) { "EPUB" -> ".epub" "PDF" -> ".pdf" + "PPTX" -> ".pptx" "MOBI" -> ".mobi" "FB2" -> ".fb2" "CBZ" -> ".cbz" diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt index b751461..2f95ef9 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt @@ -13,6 +13,7 @@ enum class PdfAnnotationKind { } enum class PdfInkTool { + NONE, PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, @@ -52,6 +53,7 @@ data class SharedPdfAnnotation( val backgroundArgb: Int = 0x00FFFFFF, val strokeWidth: Float = 2f, val fontSize: Float = 16f, + val pageRelativeFontSize: Float? = null, val isBold: Boolean = false, val isItalic: Boolean = false, val isUnderline: Boolean = false, @@ -159,6 +161,7 @@ object SharedPdfAnnotationDefaults { fun configFor(tool: PdfInkTool): PdfToolConfig { return when (tool) { + PdfInkTool.NONE -> PdfToolConfig(0x00000000, 0.008f) PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f) PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f) PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f) @@ -170,6 +173,76 @@ object SharedPdfAnnotationDefaults { } } +data class SharedPdfHighlighterPalette( + val colors: List = defaultColors +) { + fun sanitized(): SharedPdfHighlighterPalette { + val normalized = colors + .filter { it != 0 } + .map { it.withPdfHighlighterAlpha() } + .take(MaxColors) + val filled = if (normalized.isEmpty()) { + defaultColors + } else { + normalized + defaultColors.drop(normalized.size) + } + return copy(colors = filled.take(MaxColors)) + } + + fun withColorAt(slotIndex: Int, colorArgb: Int): SharedPdfHighlighterPalette { + val nextColors = sanitized().colors.toMutableList() + if (slotIndex !in nextColors.indices) return sanitized() + nextColors[slotIndex] = colorArgb.withPdfHighlighterAlpha() + return copy(colors = nextColors).sanitized() + } + + companion object { + const val DefaultAlpha: Int = 0x8C + const val MaxColors: Int = 5 + val defaultColors: List + get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() } + } +} + +object SharedPdfAndroidHighlightColors { + const val StoredAlpha: Int = 0x8C + const val RenderAlpha: Float = 0.4f + + val colorsByName: Map = mapOf( + "YELLOW" to 0xFFFBC02D.toInt(), + "GREEN" to 0xFF388E3C.toInt(), + "BLUE" to 0xFF1976D2.toInt(), + "RED" to 0xFFD32F2F.toInt() + ) + + val palette: List + get() = colorsByName.keys.map(::argbForName) + + fun argbForName(name: String): Int { + val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW") + return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF) + } + + fun nearestName(argb: Int): String { + val rgb = argb and 0x00FFFFFF + return colorsByName.minByOrNull { (_, color) -> + val candidate = color and 0x00FFFFFF + val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF) + val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF) + val db = (rgb and 0xFF) - (candidate and 0xFF) + dr * dr + dg * dg + db * db + }?.key ?: "YELLOW" + } + + fun nearestArgb(argb: Int): Int { + return argbForName(nearestName(argb)) + } +} + +private fun Int.withPdfHighlighterAlpha(): Int { + return (SharedPdfHighlighterPalette.DefaultAlpha shl 24) or (this and 0x00FFFFFF) +} + @Serializable data class SharedPdfAnnotationStore( val version: Int = 1, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt index 07c7677..9332b1f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt @@ -144,17 +144,45 @@ data class SharedPdfJumpHistory( } } +data class SharedPdfReaderViewport( + val pageIndex: Int = 0, + val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION, + val zoom: Float = PdfZoomSpec().default, + val horizontalScrollOffset: Int = 0, + val paginatedVerticalScrollOffset: Int = 0, + val verticalFirstPageIndex: Int = pageIndex, + val verticalFirstPageScrollOffset: Int = 0 +) { + fun sanitized( + pageCount: Int, + zoomSpec: PdfZoomSpec = PdfZoomSpec() + ): SharedPdfReaderViewport { + val lastPageIndex = (pageCount.coerceAtLeast(0) - 1).coerceAtLeast(0) + val safeZoom = if (zoom.isFinite() && zoom > 0f) zoom else zoomSpec.default + return copy( + pageIndex = pageIndex.coerceIn(0, lastPageIndex), + zoom = zoomSpec.clamp(safeZoom), + horizontalScrollOffset = horizontalScrollOffset.coerceAtLeast(0), + paginatedVerticalScrollOffset = paginatedVerticalScrollOffset.coerceAtLeast(0), + verticalFirstPageIndex = verticalFirstPageIndex.coerceIn(0, lastPageIndex), + verticalFirstPageScrollOffset = verticalFirstPageScrollOffset.coerceAtLeast(0) + ) + } +} + data class SharedPdfReaderState( val pageIndex: Int = 0, val pageCount: Int = 0, val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION, val zoom: Float = PdfZoomSpec().default, + val isSearchActive: Boolean = false, + val showSearchResultsPanel: Boolean = true, val searchQuery: String = "", val activeSearchResultIndex: Int = -1, val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL, - val selectedTool: PdfInkTool = PdfInkTool.PEN, - val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb, - val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, + val selectedTool: PdfInkTool = PdfInkTool.NONE, + val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).colorArgb, + val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).strokeWidth, val isTextSelectionMode: Boolean = false, val bookmarks: List = emptyList(), val selectedAnnotationId: String? = null, @@ -208,6 +236,9 @@ sealed interface SharedPdfReaderAction { data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction data class ZoomBy(val delta: Float) : SharedPdfReaderAction data class SearchChanged(val query: String) : SharedPdfReaderAction + data object SearchOpened : SharedPdfReaderAction + data object SearchClosed : SharedPdfReaderAction + data object SearchResultsPanelToggled : SharedPdfReaderAction data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction data object SearchHighlightModeToggled : SharedPdfReaderAction data class GoToSearchResult( @@ -257,10 +288,26 @@ fun SharedPdfReaderState.reduce( ) is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom)) is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta)) - is SharedPdfReaderAction.SearchChanged -> copy( - searchQuery = action.query, + is SharedPdfReaderAction.SearchChanged -> { + val normalized = action.query.trim() + copy( + isSearchActive = isSearchActive || normalized.isNotBlank(), + showSearchResultsPanel = showSearchResultsPanel || normalized.isNotBlank(), + searchQuery = action.query, + activeSearchResultIndex = -1 + ) + } + SharedPdfReaderAction.SearchOpened -> copy( + isSearchActive = true, + showSearchResultsPanel = true + ) + SharedPdfReaderAction.SearchClosed -> copy( + isSearchActive = false, + showSearchResultsPanel = true, + searchQuery = "", activeSearchResultIndex = -1 ) + SharedPdfReaderAction.SearchResultsPanelToggled -> copy(showSearchResultsPanel = !showSearchResultsPanel) is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode) SharedPdfReaderAction.SearchHighlightModeToggled -> copy( searchHighlightMode = when (searchHighlightMode) { @@ -284,12 +331,25 @@ fun SharedPdfReaderState.reduce( copy( selectedTool = action.tool, selectedColorArgb = config.colorArgb, - strokeWidth = config.strokeWidth + strokeWidth = config.strokeWidth, + isTextSelectionMode = false ) } is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb) is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f)) - is SharedPdfReaderAction.TextSelectionModeChanged -> copy(isTextSelectionMode = action.enabled) + is SharedPdfReaderAction.TextSelectionModeChanged -> { + if (action.enabled) { + val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE) + copy( + isTextSelectionMode = true, + selectedTool = PdfInkTool.NONE, + selectedColorArgb = config.colorArgb, + strokeWidth = config.strokeWidth + ) + } else { + copy(isTextSelectionMode = false) + } + } is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex)) is SharedPdfReaderAction.BookmarkToggled -> { val page = action.pageIndex.coerceIn(0, lastPageIndex) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt index 04e68eb..135c480 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt @@ -1,5 +1,9 @@ package com.aryan.reader.shared.pdf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.floor + data class PdfVisiblePageLayout( val pageIndex: Int, val top: Float, @@ -28,3 +32,73 @@ fun mostVisiblePdfPageIndex( ?.pageIndex ?: fallbackPageIndex } + +fun pdfVerticalPageGapDp( + isPageGapVisible: Boolean, + defaultGap: Dp +): Dp = if (isPageGapVisible) defaultGap else 0.dp + +data class PdfVerticalPagePlacement( + val pageIndex: Int, + val topPx: Int, + val widthPx: Int, + val heightPx: Int +) { + val bottomPx: Int + get() = topPx + heightPx +} + +data class PdfVerticalPageLayoutResult( + val pages: List, + val totalHeightPx: Int +) + +fun calculatePdfVerticalPageLayoutPx( + pageAspectRatios: List, + viewportWidthPx: Int, + viewportHeightPx: Int, + pageGapPx: Int +): PdfVerticalPageLayoutResult { + val safeWidthPx = viewportWidthPx.coerceAtLeast(0) + if (pageAspectRatios.isEmpty() || safeWidthPx == 0) { + return PdfVerticalPageLayoutResult(emptyList(), 0) + } + + val safeGapPx = pageGapPx.coerceAtLeast(0) + val safeHeightPx = viewportHeightPx.coerceAtLeast(0) + + fun pageHeightPx(ratio: Float): Int { + val safeRatio = if (ratio <= 0f) 1f else ratio + return floor(safeWidthPx.toDouble() / safeRatio.toDouble()) + .toInt() + .coerceAtLeast(1) + } + + var currentTopPx = 0 + if (pageAspectRatios.size == 1) { + val singlePageHeightPx = pageHeightPx(pageAspectRatios[0]) + if (singlePageHeightPx < safeHeightPx) { + currentTopPx = (safeHeightPx - singlePageHeightPx) / 2 + } + } + + val pages = pageAspectRatios.mapIndexed { index, ratio -> + val heightPx = pageHeightPx(ratio) + val placement = PdfVerticalPagePlacement( + pageIndex = index, + topPx = currentTopPx, + widthPx = safeWidthPx, + heightPx = heightPx + ) + currentTopPx += heightPx + if (index < pageAspectRatios.lastIndex) { + currentTopPx += safeGapPx + } + placement + } + + return PdfVerticalPageLayoutResult( + pages = pages, + totalHeightPx = pages.lastOrNull()?.bottomPx ?: 0 + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt index 53dccee..c80968f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt @@ -16,7 +16,6 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull -import kotlin.math.pow object SharedPdfAnnotationSidecarCodec { const val KEY_PDF_ANNOTATIONS = "pdfAnnotations" @@ -24,8 +23,6 @@ object SharedPdfAnnotationSidecarCodec { const val KEY_LEGACY_TEXT_BOXES = "textBoxes" const val KEY_LEGACY_HIGHLIGHTS = "highlights" - private const val LEGACY_TEXT_BOX_FONT_REFERENCE_DP = 500f - private val json = Json { ignoreUnknownKeys = true encodeDefaults = true @@ -56,7 +53,7 @@ object SharedPdfAnnotationSidecarCodec { fun withCanonicalAnnotations(data: JsonObject): JsonObject { if (data[KEY_PDF_ANNOTATIONS] != null) return data val annotations = annotationsFromData(data) - if (annotations.isEmpty()) return data + if (annotations.isEmpty() && !data.hasLegacyAndroidAnnotationPayload()) return data return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations))) } @@ -69,16 +66,17 @@ object SharedPdfAnnotationSidecarCodec { annotations: List, existingData: JsonObject = JsonObject(emptyMap()) ): JsonObject { - if (annotations.isEmpty()) return existingData - val next = existingData.toMutableMap() - if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) { + val replaceLegacyFromCanonical = existingData[KEY_PDF_ANNOTATIONS] != null + if (annotations.isEmpty() && !replaceLegacyFromCanonical) return existingData + + if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) { next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray() } - if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) { + if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) { next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray() } - if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) { + if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) { next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray() } return JsonObject(next) @@ -87,7 +85,7 @@ object SharedPdfAnnotationSidecarCodec { fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String { val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson val annotations = annotationsFromData(data) - if (annotations.isEmpty()) return rawDataJson + if (annotations.isEmpty() && data[KEY_PDF_ANNOTATIONS] == null) return rawDataJson return json.encodeToString( JsonElement.serializer(), legacyAndroidDataFromAnnotations(annotations, data) @@ -128,6 +126,7 @@ object SharedPdfAnnotationSidecarCodec { kind = PdfAnnotationKind.INK, tool = tool.toPdfInkTool(), points = points, + note = obj.string("note"), colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb, strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, createdAt = points.firstOrNull()?.timestamp ?: 0L @@ -151,7 +150,8 @@ object SharedPdfAnnotationSidecarCodec { colorArgb = obj.int("color") ?: 0xFF000000.toInt(), backgroundArgb = obj.int("backgroundColor") ?: 0x00000000, strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, - fontSize = rawFontSize.legacyTextBoxFontSizeToShared(), + fontSize = SharedPdfTextAnnotationDefaults.pageRelativeFontSizeToDisplay(rawFontSize), + pageRelativeFontSize = SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(rawFontSize), isBold = obj.boolean("isBold") ?: false, isItalic = obj.boolean("isItalic") ?: false, isUnderline = obj.boolean("isUnderline") ?: false, @@ -189,7 +189,7 @@ object SharedPdfAnnotationSidecarCodec { boundsList = boundsList, text = obj.string("text").orEmpty(), note = obj.string("note"), - colorArgb = colorName.toSharedHighlightArgb(), + colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName), rangeStartIndex = rangeStart, rangeEndIndex = inclusiveRangeEnd ) @@ -208,6 +208,7 @@ object SharedPdfAnnotationSidecarCodec { put("inkType", JsonPrimitive(annotation.tool.name)) put("color", JsonPrimitive(annotation.colorArgb)) put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble())) + annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) } put( "points", JsonArray( @@ -240,7 +241,7 @@ object SharedPdfAnnotationSidecarCodec { put("text", JsonPrimitive(annotation.text)) put("color", JsonPrimitive(annotation.colorArgb)) put("backgroundColor", JsonPrimitive(annotation.backgroundArgb)) - put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble())) + put("fontSize", JsonPrimitive(annotation.sharedPdfTextPageRelativeFontSize().toDouble())) put("isBold", JsonPrimitive(annotation.isBold)) put("isItalic", JsonPrimitive(annotation.isItalic)) put("isUnderline", JsonPrimitive(annotation.isUnderline)) @@ -262,7 +263,7 @@ object SharedPdfAnnotationSidecarCodec { buildMap { put("id", JsonPrimitive(annotation.id)) put("pageIndex", JsonPrimitive(annotation.pageIndex)) - put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName())) + put("color", JsonPrimitive(SharedPdfAndroidHighlightColors.nearestName(annotation.colorArgb))) put("text", JsonPrimitive(annotation.text)) val rangeStart = annotation.rangeStartIndex ?: 0 val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart @@ -305,6 +306,12 @@ object SharedPdfAnnotationSidecarCodec { private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null + private fun JsonObject.hasLegacyAndroidAnnotationPayload(): Boolean { + return this[KEY_LEGACY_INK] != null || + this[KEY_LEGACY_TEXT_BOXES] != null || + this[KEY_LEGACY_HIGHLIGHTS] != null + } + private fun JsonElement.jsonArrayOrNull(): JsonArray? { if (this is JsonNull) return null return runCatching { jsonArray }.getOrNull() @@ -378,38 +385,4 @@ object SharedPdfAnnotationSidecarCodec { return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN) } - private fun Float.legacyTextBoxFontSizeToShared(): Float { - return if (this in 0f..1f) { - (this * LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(8f, 48f) - } else { - coerceIn(8f, 96f) - } - } - - private fun Float.sharedFontSizeToLegacyTextBox(): Float { - return (this / LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(0.012f, 0.12f) - } - - private fun String.toSharedHighlightArgb(): Int { - val opaqueArgb = legacyHighlightColors[uppercase()] ?: legacyHighlightColors.getValue("YELLOW") - return 0x8C000000.toInt() or (opaqueArgb and 0x00FFFFFF) - } - - private fun Int.toLegacyHighlightColorName(): String { - val rgb = this and 0x00FFFFFF - return legacyHighlightColors.minByOrNull { (_, color) -> - val candidate = color and 0x00FFFFFF - val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF) - val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF) - val db = (rgb and 0xFF) - (candidate and 0xFF) - dr.toDouble().pow(2) + dg.toDouble().pow(2) + db.toDouble().pow(2) - }?.key ?: "YELLOW" - } - - private val legacyHighlightColors = mapOf( - "YELLOW" to 0xFFFBC02D.toInt(), - "GREEN" to 0xFF388E3C.toInt(), - "BLUE" to 0xFF1976D2.toInt(), - "RED" to 0xFFD32F2F.toInt() - ) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt index 0f4a0bf..184b41d 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt @@ -42,6 +42,7 @@ object SharedPdfInkRenderer { canvasSize: IntSize ): SharedPdfInkRenderData? { if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null + if (annotation.tool == PdfInkTool.NONE) return null val widthPx = canvasSize.width.coerceAtLeast(1).toFloat() val heightPx = canvasSize.height.coerceAtLeast(1).toFloat() val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx) @@ -87,6 +88,7 @@ object SharedPdfInkRenderer { } return when (annotation.tool) { + PdfInkTool.NONE -> null PdfInkTool.PENCIL -> { val path = annotation.points.toSmoothPath(widthPx, heightPx) val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx) @@ -335,6 +337,7 @@ object SharedPdfInkRenderer { fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange { return when (this) { + PdfInkTool.NONE -> 0.001f..0.015f PdfInkTool.HIGHLIGHTER, PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f PdfInkTool.ERASER -> 0.002f..0.10f diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt index 5956c1a..2a174b3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt @@ -45,21 +45,17 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlin.math.abs const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C' private const val SHARED_PDF_ZWSP = "\u200B" private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path" -const val SHARED_PDF_RICH_TEXT_LOG_TAG: String = "PdfRichTextTrace" - object SharedPdfRichTextLog { var enabled: Boolean = true fun d(message: String) { - if (enabled) { - println("$SHARED_PDF_RICH_TEXT_LOG_TAG $message") - } } } @@ -621,10 +617,26 @@ class SharedPdfRichTextController( fun updateLayoutConfig(width: Float, height: Float, density: Density, measurer: TextMeasurer) { if (lastPageWidth != width || lastPageHeight != height || lastDensity != density || lastTextMeasurer != measurer) { + val previousPageHeight = lastPageHeight + val fontScale = if (previousPageHeight > 0f && height > 0f) { + height / previousPageHeight + } else { + 1f + } + val shouldScaleFonts = abs(fontScale - 1f) > 0.001f SharedPdfRichTextLog.d( "controller.layoutConfig width=${width.richLogFloat()} height=${height.richLogFloat()} " + - "density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${lastPageHeight.richLogFloat()}" + "density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${previousPageHeight.richLogFloat()} " + + "fontScale=${fontScale.richLogFloat()}" ) + if (shouldScaleFonts) { + saveJob?.cancel() + globalTextFieldValue = globalTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale) + localTextFieldValue = localTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale) + if (globalTextFieldValue.text.isNotEmpty()) { + debouncedSave(globalTextFieldValue) + } + } lastPageWidth = width lastPageHeight = height lastDensity = density @@ -1534,6 +1546,45 @@ fun SharedPdfTextStyleConfig.toSharedPdfRichSpanStyle(): SpanStyle { ) } +internal fun AnnotatedString.withScaledSharedPdfRichFontSizes(scale: Float): AnnotatedString { + if (!scale.isFinite() || scale <= 0f || abs(scale - 1f) <= 0.001f) return this + if (spanStyles.none { it.item.fontSize.isSp }) return this + + val builder = AnnotatedString.Builder(text) + spanStyles.forEach { range -> + val style = range.item + builder.addStyle( + style = if (style.fontSize.isSp) { + style.copy(fontSize = (style.fontSize.value * scale).sp) + } else { + style + }, + start = range.start, + end = range.end + ) + } + paragraphStyles.forEach { range -> + builder.addStyle(range.item, range.start, range.end) + } + getStringAnnotations( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + start = 0, + end = length + ).forEach { annotation -> + builder.addStringAnnotation( + tag = annotation.tag, + annotation = annotation.item, + start = annotation.start, + end = annotation.end + ) + } + return builder.toAnnotatedString() +} + +private fun TextFieldValue.withScaledSharedPdfRichFontSizes(scale: Float): TextFieldValue { + return copy(annotatedString = annotatedString.withScaledSharedPdfRichFontSizes(scale)) +} + fun SharedPdfRichTextController.currentSharedPdfTextStyleConfig(): SharedPdfTextStyleConfig { val decoration = currentStyle.textDecoration ?: TextDecoration.None return SharedPdfTextStyleConfig( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt index ec9eefb..7ee25aa 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt @@ -9,6 +9,7 @@ data class SharedPdfTextStyleConfig( val colorArgb: Int = 0xFF000000.toInt(), val backgroundColorArgb: Int = 0x00000000, val fontSize: Float = 16f, + val pageRelativeFontSize: Float? = null, val isBold: Boolean = false, val isItalic: Boolean = false, val isUnderline: Boolean = false, @@ -46,6 +47,10 @@ data class SharedPdfTextDraft( ) object SharedPdfTextAnnotationDefaults { + private const val AndroidTextBoxFontReferencePx = 500f + private const val MinPageRelativeFontSize = 0.012f + private const val MaxPageRelativeFontSize = 0.12f + val fontSizes: List = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f) val fontPresets: List = listOf( @@ -97,6 +102,7 @@ object SharedPdfTextAnnotationDefaults { backgroundArgb = style.backgroundColorArgb, strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, fontSize = style.fontSize, + pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(), isBold = style.isBold, isItalic = style.isItalic, isUnderline = style.isUnderline, @@ -133,9 +139,10 @@ object SharedPdfTextAnnotationDefaults { ): PdfPageBounds { val widthPx = canvasSize.width.coerceAtLeast(1).toFloat() val heightPx = canvasSize.height.coerceAtLeast(1).toFloat() - val widthNorm = estimateWidthNorm(text, style, widthPx).coerceIn(0.18f, 0.62f) - val lineCount = estimateLineCount(text, style.fontSize, widthPx * widthNorm) - val heightNorm = (((style.fontSize * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f) + val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize) + val widthNorm = estimateWidthNorm(text, fontSizePx, widthPx).coerceIn(0.18f, 0.62f) + val lineCount = estimateLineCount(text, fontSizePx, widthPx * widthNorm) + val heightNorm = (((fontSizePx * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f) val left = anchor.x.coerceIn(0f, 1f - widthNorm) val top = anchor.y.coerceIn(0f, 1f - heightNorm) return PdfPageBounds( @@ -158,13 +165,34 @@ object SharedPdfTextAnnotationDefaults { private fun estimateWidthNorm( text: String, - style: SharedPdfTextStyleConfig, + fontSizePx: Float, pageWidthPx: Float ): Float { val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0 - val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f + val estimatedTextWidth = (longestLine.coerceAtLeast(12) * fontSizePx * 0.55f) + 18f return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f) } + + internal fun displayFontSizeToPageRelative(fontSize: Float): Float { + return (fontSize / AndroidTextBoxFontReferencePx) + .coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize) + } + + internal fun pageRelativeFontSizeToDisplay(fontSize: Float): Float { + return if (fontSize in 0f..1f) { + (fontSize * AndroidTextBoxFontReferencePx).coerceIn(8f, 48f) + } else { + fontSize.coerceIn(8f, 96f) + } + } + + internal fun legacyFontSizeToPageRelative(fontSize: Float): Float { + return if (fontSize in 0f..1f) { + fontSize.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize) + } else { + displayFontSizeToPageRelative(fontSize) + } + } } fun SharedPdfTextDraft.withText( @@ -225,6 +253,7 @@ fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation { backgroundArgb = style.backgroundColorArgb, strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, fontSize = style.fontSize, + pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(), isBold = style.isBold, isItalic = style.isItalic, isUnderline = style.isUnderline, @@ -316,6 +345,7 @@ fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig { colorArgb = colorArgb, backgroundColorArgb = backgroundArgb, fontSize = fontSize, + pageRelativeFontSize = pageRelativeFontSize, isBold = isBold, isItalic = isItalic, isUnderline = isUnderline, @@ -330,6 +360,7 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): colorArgb = style.colorArgb, backgroundArgb = style.backgroundColorArgb, fontSize = style.fontSize, + pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(), isBold = style.isBold, isItalic = style.isItalic, isUnderline = style.isUnderline, @@ -339,6 +370,35 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): ) } +fun SharedPdfTextStyleConfig.withSharedPdfTextFontSize(fontSize: Float): SharedPdfTextStyleConfig { + return copy( + fontSize = fontSize, + pageRelativeFontSize = SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize) + ) +} + +fun SharedPdfTextStyleConfig.sharedPdfTextPageRelativeFontSize(): Float { + return pageRelativeFontSize + ?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) } + ?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize) +} + +fun SharedPdfTextStyleConfig.sharedPdfTextFontSizePx(canvasSize: IntSize): Float { + val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat() + return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f) +} + +fun SharedPdfAnnotation.sharedPdfTextPageRelativeFontSize(): Float { + return pageRelativeFontSize + ?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) } + ?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize) +} + +fun SharedPdfAnnotation.sharedPdfTextFontSizePx(canvasSize: IntSize): Float { + val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat() + return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f) +} + private fun PdfPageBounds.coercedToPage(): PdfPageBounds { val coercedLeft = left.coerceIn(0f, 1f) val coercedTop = top.coerceIn(0f, 1f) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt index c3f6f1c..470d60f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt @@ -53,7 +53,8 @@ data class ReaderSessionState( val searchResults: List = emptyList(), val activeSearchResultIndex: Int = -1, val navigationLocator: ReaderLocator? = null, - val navigationRequestId: Long = 0L + val navigationRequestId: Long = 0L, + val jumpHistory: ReaderJumpHistory = ReaderJumpHistory() ) { val currentBookmark: ReaderBookmark? get() = navigationLocator @@ -84,7 +85,7 @@ class ReaderEngine( private data class PaginationCacheKey( val bookId: String, val chapterSignature: Int, - val settings: ReaderSettings + val layoutSignature: ReaderLayoutSignature ) private val paginationCache = object : LinkedHashMap>(8, 0.75f, true) { @@ -97,11 +98,16 @@ class ReaderEngine( book: SharedEpubBook, settings: ReaderSettings = ReaderSettings(), initialPageIndex: Int = 0, + initialLocator: ReaderLocator? = null, bookmarks: List = emptyList(), highlights: List = emptyList() ): ReaderSessionState { val pages = pagesFor(book, settings) - val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)) + val requestedInitialIndex = initialLocator + ?.let { pages.findPageIndexForLocator(it) } + ?.takeIf { it >= 0 } + ?: initialPageIndex + val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings) val reader = PaginatedReaderState( book = book, pages = pages, @@ -118,22 +124,30 @@ class ReaderEngine( .map { it.withNormalizedLocator() } .filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices } .distinctBy { it.id }, - navigationLocator = reader.currentPage?.toLocator(book) + navigationLocator = initialLocator + ?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))) + ?: reader.currentPage?.toLocator(book) ) } fun next(state: ReaderSessionState): ReaderSessionState { if (!state.reader.canGoNext) return state - return goToPage(state, state.reader.currentPageIndex + 1) + return goToPage( + state, + ReaderSpreadLayout.nextPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings) + ) } fun previous(state: ReaderSessionState): ReaderSessionState { if (!state.reader.canGoPrevious) return state - return goToPage(state, state.reader.currentPageIndex - 1) + return goToPage( + state, + ReaderSpreadLayout.previousPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings) + ) } fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState { - val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) + val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(target) return state.copy( reader = state.reader.copy(currentPageIndex = target), @@ -159,31 +173,85 @@ class ReaderEngine( } fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState { - val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } .takeIf { it >= 0 } ?: locator.pageIndex ?.takeIf { it in state.reader.pages.indices } ?: return state + val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(pageIndex) ?: return state - val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) - val normalizedLocator = locator.copy(pageIndex = pageIndex).withFallbacks( - chapterIndex = page.chapterIndex, - chapterId = chapter?.id, - href = chapter?.baseHref, - pageIndex = pageIndex, - startOffset = page.startOffset, - endOffset = page.endOffset, - textQuote = locator.textQuote ?: page.text.preview(), - cfi = locator.cfi ?: page.toDesktopCfi() + val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page + val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex) + val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks( + chapterIndex = requestedPage.chapterIndex, + chapterId = requestedChapter?.id, + href = requestedChapter?.baseHref, + pageIndex = requestedPageIndex, + startOffset = requestedPage.startOffset, + endOffset = requestedPage.endOffset, + textQuote = locator.textQuote ?: requestedPage.text.preview(), + cfi = locator.cfi ?: requestedPage.toDesktopCfi() ) return state.copy( reader = state.reader.copy(currentPageIndex = pageIndex), - activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex }, + activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == requestedPageIndex }, navigationLocator = normalizedLocator, navigationRequestId = state.navigationRequestId + 1 ) } + fun jumpToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState { + return goToPage(state, pageIndex).withRecordedJumpFrom(state) + } + + fun jumpToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState { + return jumpToPage(state, pageNumber - 1) + } + + fun jumpToChapter(state: ReaderSessionState, chapterIndex: Int): ReaderSessionState { + return goToChapter(state, chapterIndex).withRecordedJumpFrom(state) + } + + fun jumpToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState { + return goToLocator(state, locator).withRecordedJumpFrom(state, requestedTarget = locator) + } + + fun jumpToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState { + return goToSearchResult(state, resultIndex).withRecordedJumpFrom(state) + } + + fun jumpToNextSearchResult(state: ReaderSessionState): ReaderSessionState { + return nextSearchResult(state).withRecordedJumpFrom(state) + } + + fun jumpToPreviousSearchResult(state: ReaderSessionState): ReaderSessionState { + return previousSearchResult(state).withRecordedJumpFrom(state) + } + + fun jumpBack(state: ReaderSessionState): ReaderSessionState { + if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) { + return state.copy(jumpHistory = state.jumpHistory.clear()) + } + val history = state.jumpHistory.pruned(state.reader.book.chapters.size) + val target = history.backLocator ?: return state.copy(jumpHistory = history) + return goToLocator(state.copy(jumpHistory = history), target) + .copy(jumpHistory = history.stepBack()) + } + + fun jumpForward(state: ReaderSessionState): ReaderSessionState { + if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) { + return state.copy(jumpHistory = state.jumpHistory.clear()) + } + val history = state.jumpHistory.pruned(state.reader.book.chapters.size) + val target = history.forwardLocator ?: return state.copy(jumpHistory = history) + return goToLocator(state.copy(jumpHistory = history), target) + .copy(jumpHistory = history.stepForward()) + } + + fun clearJumpHistory(state: ReaderSessionState): ReaderSessionState { + return state.copy(jumpHistory = state.jumpHistory.clear()) + } + fun resolveLink( state: ReaderSessionState, href: String, @@ -275,43 +343,112 @@ class ReaderEngine( } fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState { - val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) - val normalizedLocator = locator?.normalizedForPage(state, target) + val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings) + val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))) if (target == state.reader.currentPageIndex && normalizedLocator == null) return state return state.copy( reader = state.reader.copy(currentPageIndex = target), - activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }, + activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex }, navigationLocator = normalizedLocator ?: state.navigationLocator ) } fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState { - val current = state.reader.currentPage - val pages = pagesFor(state.reader.book, settings) - val newIndex = if (current == null) { - 0 + val layoutChanged = state.reader.settings.layoutSignature() != settings.layoutSignature() + val nextJumpHistory = if (settings.readingMode == ReaderReadingMode.PAGINATED) { + state.jumpHistory.clear() } else { - pages.indexOfFirst { - it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset - }.takeIf { it >= 0 } ?: 0 + state.jumpHistory } + if (!layoutChanged) { + return state.copy( + reader = state.reader.copy(settings = settings), + jumpHistory = nextJumpHistory + ) + } + val anchor = state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book) + val pages = pagesFor(state.reader.book, settings) + val requestedIndex = anchor + ?.let { pages.findPageIndexForLocator(it) } + ?.takeIf { it >= 0 } + ?: 0 + val newIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, settings) + val normalizedLocator = anchor + ?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))) + ?: pages.getOrNull(newIndex)?.toLocator(state.reader.book) val updated = state.copy( reader = state.reader.copy( pages = pages, - currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)), + currentPageIndex = newIndex, settings = settings - ) + ), + navigationLocator = normalizedLocator, + jumpHistory = nextJumpHistory ) return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) else updated } + fun reflowAnchorFor(state: ReaderSessionState): ReaderLocator? { + return state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book) + } + + fun replacePages( + state: ReaderSessionState, + pages: List, + reflowAnchor: ReaderLocator? = null, + navigationRequestIdAtReflowStart: Long? = null + ): ReaderSessionState { + if (pages.isEmpty()) return state + val explicitNavigationAfterReflowStarted = navigationRequestIdAtReflowStart != null && + state.navigationRequestId != navigationRequestIdAtReflowStart + val anchor = when { + explicitNavigationAfterReflowStarted -> + state.navigationLocator ?: state.activeSearchResult?.locator ?: state.reader.currentPage?.toLocator(state.reader.book) + reflowAnchor != null -> reflowAnchor + else -> state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book) + } + val targetIndex = anchor + ?.let { locator -> pages.findPageIndexForLocator(locator) } + ?.takeIf { it >= 0 } + ?: state.reader.currentPage?.let { current -> + pages.indexOfFirst { + it.chapterIndex == current.chapterIndex && + it.startOffset <= current.startOffset && + it.endOffset >= current.startOffset + }.takeIf { it >= 0 } + } + ?: state.reader.currentPageIndex + val requestedIndex = targetIndex.coerceIn(0, pages.lastIndex) + val normalizedIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, state.reader.settings) + val normalizedLocator = anchor + ?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex) + ?: pages.getOrNull(normalizedIndex)?.toLocator(state.reader.book) + val activeSearchIndex = normalizedLocator + ?.let { locator -> state.searchResults.indexOfFirst { it.locator.sameLocation(locator) } } + ?: -1 + val updated = state.copy( + reader = state.reader.copy( + pages = pages, + currentPageIndex = normalizedIndex + ), + activeSearchResultIndex = activeSearchIndex, + navigationLocator = normalizedLocator, + jumpHistory = if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) { + state.jumpHistory.clear() + } else { + state.jumpHistory + } + ) + return if (updated.searchQuery.isNotBlank()) refreshSearchResults(updated) else updated + } + private fun pagesFor(book: SharedEpubBook, settings: ReaderSettings): List { val key = PaginationCacheKey( bookId = book.id, chapterSignature = book.chapters.fold(1) { acc, chapter -> 31 * acc + chapter.id.hashCode() + chapter.plainText.length + chapter.plainText.hashCode() }, - settings = settings + layoutSignature = settings.layoutSignature() ) return synchronized(paginationCache) { paginationCache.getOrPut(key) { @@ -463,47 +600,59 @@ class ReaderEngine( fun search(state: ReaderSessionState, query: String): ReaderSessionState { val normalized = query.trim() - val results = if (normalized.isBlank()) { - emptyList() - } else { - state.reader.pages.flatMap { page -> - val matches = mutableListOf() - var startIndex = 0 - while (startIndex < page.text.length) { - val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions) - if (index < 0) break - val endIndex = (index + normalized.length).coerceAtMost(page.text.length) - matches += - ReaderSearchResult( - pageIndex = page.pageIndex, - chapterTitle = page.chapterTitle, - preview = page.text.previewAround(index, normalized.length), - matchIndex = index, - chapterIndex = page.chapterIndex, - locator = ReaderLocator( - chapterIndex = page.chapterIndex, - pageIndex = page.pageIndex, - startOffset = page.startOffset + index, - endOffset = page.startOffset + endIndex, - textQuote = page.text.substring(index, endIndex) - ) - ) - startIndex = index + normalized.length.coerceAtLeast(1) - } - matches - } - } - val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex } - .takeIf { it >= 0 } - ?: if (results.isNotEmpty()) 0 else -1 - val updated = state.copy( + val results = searchResultsFor(state, normalized) + return state.copy( isSearchActive = state.isSearchActive || normalized.isNotBlank(), showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(), searchQuery = query, + searchResults = results, + activeSearchResultIndex = -1 + ) + } + + private fun refreshSearchResults(state: ReaderSessionState): ReaderSessionState { + val normalized = state.searchQuery.trim() + val results = searchResultsFor(state, normalized) + val previousLocator = state.activeSearchResult?.locator + val activeIndex = previousLocator + ?.let { locator -> results.indexOfFirst { it.locator.sameLocation(locator) } } + ?.takeIf { it >= 0 } + ?: results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }.takeIf { it >= 0 } + ?: if (results.isNotEmpty()) 0 else -1 + return state.copy( searchResults = results, activeSearchResultIndex = activeIndex ) - return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated + } + + private fun searchResultsFor(state: ReaderSessionState, normalized: String): List { + if (normalized.isBlank()) return emptyList() + return state.reader.pages.flatMap { page -> + val matches = mutableListOf() + var startIndex = 0 + while (startIndex < page.text.length) { + val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions) + if (index < 0) break + val endIndex = (index + normalized.length).coerceAtMost(page.text.length) + matches += + ReaderSearchResult( + pageIndex = page.pageIndex, + chapterTitle = page.chapterTitle, + preview = page.text.previewAround(index, normalized.length), + matchIndex = index, + chapterIndex = page.chapterIndex, + locator = ReaderLocator( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + startOffset = page.startOffset + index, + endOffset = page.startOffset + endIndex, + textQuote = page.text.substring(index, endIndex) + ) + ) + startIndex = index + normalized.length.coerceAtLeast(1) + } + matches + } } fun nextSearchResult(state: ReaderSessionState): ReaderSessionState { @@ -530,25 +679,51 @@ class ReaderEngine( if (state.searchResults.isEmpty()) return state val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex) val result = state.searchResults[targetIndex] - val targetPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) } + val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) } .takeIf { it >= 0 } ?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) + val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(targetPage) val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) } return state.copy( reader = state.reader.copy(currentPageIndex = targetPage), activeSearchResultIndex = targetIndex, - navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks( + navigationLocator = result.locator.copy(pageIndex = requestedPage).withFallbacks( chapterIndex = page?.chapterIndex, chapterId = chapter?.id, href = chapter?.baseHref, - pageIndex = targetPage + pageIndex = requestedPage ), navigationRequestId = state.navigationRequestId + 1 ) } } +private fun ReaderSessionState.withRecordedJumpFrom( + previous: ReaderSessionState, + requestedTarget: ReaderLocator? = null +): ReaderSessionState { + if ( + previous.reader.settings.readingMode == ReaderReadingMode.PAGINATED || + reader.settings.readingMode == ReaderReadingMode.PAGINATED + ) { + return copy(jumpHistory = previous.jumpHistory.clear()) + } + val current = previous.currentJumpLocator() + val target = navigationLocator ?: requestedTarget ?: currentJumpLocator() + return copy( + jumpHistory = previous.jumpHistory.record( + currentLocator = current, + targetLocator = target, + chapterCount = reader.book.chapters.size + ) + ) +} + +private fun ReaderSessionState.currentJumpLocator(): ReaderLocator? { + return navigationLocator ?: reader.currentPage?.toLocator(reader.book) +} + private fun ReaderPage.contains(locator: ReaderLocator): Boolean { val targetChapter = locator.chapterIndex if (targetChapter != null && targetChapter != chapterIndex) return false @@ -565,6 +740,34 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean { return targetPage != null && targetPage == pageIndex } +private fun List.findPageIndexForLocator(locator: ReaderLocator): Int { + return indexOfFirst { page -> page.contains(locator) } + .takeIf { it >= 0 } + ?: locator.pageIndex?.takeIf { it in indices } + ?: -1 +} + +private fun ReaderLocator.normalizedForResolvedPage( + book: SharedEpubBook, + pages: List, + pageIndex: Int +): ReaderLocator? { + val page = pages.getOrNull(pageIndex) ?: return null + val chapter = book.chapters.getOrNull(page.chapterIndex) + val start = startOffset ?: page.startOffset + val end = (endOffset ?: start).coerceAtLeast(start) + return copy(pageIndex = page.pageIndex).withFallbacks( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = page.pageIndex, + startOffset = start, + endOffset = end, + textQuote = textQuote ?: page.text.preview(), + cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end" + ) +} + private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List): ReaderBookmark? { val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) } .takeIf { it >= 0 } @@ -796,5 +999,5 @@ private fun Char?.isWordChar(): Boolean { } private fun logReaderLink(message: String) { - println("ReaderLinkResolve $message") + logSharedReaderDiagnostic("ReaderLinkResolve") { message } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt index 5d550c8..8b38f1f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt @@ -21,6 +21,8 @@ import com.aryan.reader.shared.UserHighlight import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified +import kotlin.math.abs +import kotlin.math.pow import kotlin.math.roundToInt object ReaderHtmlDocumentBuilder { @@ -35,6 +37,7 @@ object ReaderHtmlDocumentBuilder { pages: List = emptyList(), readerAiFeaturesEnabled: Boolean = true, cloudTtsEnabled: Boolean = true, + externalLookupEnabled: Boolean = true, textureDataUri: String? = null ): String { val body = book.chapters.mapIndexed { index, chapter -> @@ -66,6 +69,7 @@ object ReaderHtmlDocumentBuilder { pageAnchors = pages, readerAiFeaturesEnabled = readerAiFeaturesEnabled, cloudTtsEnabled = cloudTtsEnabled, + externalLookupEnabled = externalLookupEnabled, textureDataUri = textureDataUri ) } @@ -73,6 +77,7 @@ object ReaderHtmlDocumentBuilder { fun pageDocument( book: SharedEpubBook, page: ReaderPage?, + visiblePages: List = listOfNotNull(page), settings: ReaderSettings, searchQuery: String = "", searchOptions: ReaderSearchOptions = ReaderSearchOptions(), @@ -81,57 +86,114 @@ object ReaderHtmlDocumentBuilder { navigationLocator: ReaderLocator? = null, readerAiFeaturesEnabled: Boolean = true, cloudTtsEnabled: Boolean = true, + externalLookupEnabled: Boolean = true, textureDataUri: String? = null ): String { - val chapter = page?.let { book.chapters.getOrNull(it.chapterIndex) } - val body = if (page == null || chapter == null) { + val paginatedSettings = settings.copy(readingMode = ReaderReadingMode.PAGINATED) + val pagesToRender = visiblePages.ifEmpty { listOfNotNull(page) } + val body = if (pagesToRender.isEmpty()) { logReaderHtml("page_document_empty reason=missing_page_or_chapter") "
" } else { - val semanticPageBlocks = chapter.semanticBlocks.blocksForPage(page) - val usedSemanticBlocks = semanticPageBlocks.isNotEmpty() - val blocks = if (usedSemanticBlocks) { - semanticPageBlocks.joinToString("") { it.toHtml(searchQuery, searchOptions) } - } else { - page.text.textToParagraphHtml(searchQuery, searchOptions, baseOffset = page.startOffset) + val sections = pagesToRender.mapNotNull { readerPage -> + pageSectionHtml( + book = book, + page = readerPage, + settings = paginatedSettings, + searchQuery = searchQuery, + searchOptions = searchOptions, + highlights = highlights + ) + } + if (sections.size > 1) { + sections.joinToString("\n", "
", "
") + } else { + sections.firstOrNull() ?: "
" } - val pageHtml = blocks.applyUserHighlights( - highlights = highlights.filter { it.belongsToPage(page) }, - contentStartOffset = page.startOffset, - contentEndOffset = page.endOffset - ) - logReaderHtml( - "page_document page=${page.pageIndex + 1} chapter=${page.chapterIndex} " + - "range=${page.startOffset}..${page.endOffset} pageText=${page.text.length} " + - "semantic=$usedSemanticBlocks blocks=${semanticPageBlocks.size}/${chapter.semanticBlocks.size} " + - "htmlChars=${pageHtml.length} settingsFont=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + - "summary=\"${semanticPageBlocks.blockSummary()}\" styles=\"${semanticPageBlocks.styleSummary()}\"" - ) - """ -
-

${page.chapterTitle.escapeHtml()}

-
- $pageHtml -
-
- """.trimIndent() } return document( title = book.title, - settings = settings, + settings = paginatedSettings, bookCss = book.css.values.joinToString("\n"), body = body, searchQuery = searchQuery, searchOptions = searchOptions, highlightPalette = highlightPalette, navigationLocator = navigationLocator, - pageAnchors = emptyList(), + pageAnchors = pagesToRender, readerAiFeaturesEnabled = readerAiFeaturesEnabled, cloudTtsEnabled = cloudTtsEnabled, + externalLookupEnabled = externalLookupEnabled, textureDataUri = textureDataUri ) } + fun appearanceUpdateScript( + settings: ReaderSettings, + textureDataUri: String? = null + ): String { + val appearance = settings.toDocumentAppearanceCss(textureDataUri) + return """ + (function () { + var root = document.documentElement; + if (!root) return; + root.style.colorScheme = ${appearance.colorScheme.toJsStringLiteral()}; + root.style.setProperty('--reader-bg', ${appearance.background.toJsStringLiteral()}); + root.style.setProperty('--reader-fg', ${appearance.foreground.toJsStringLiteral()}); + root.style.setProperty('--reader-link', ${appearance.linkColors.color.toJsStringLiteral()}); + root.style.setProperty('--reader-link-decoration', ${appearance.linkColors.decoration.toJsStringLiteral()}); + root.style.setProperty('--reader-link-bg', ${appearance.linkColors.background.toJsStringLiteral()}); + root.style.setProperty('--reader-highlight', ${appearance.highlight.toJsStringLiteral()}); + var textureStyle = document.getElementById('reader-texture-style'); + if (!textureStyle) { + textureStyle = document.createElement('style'); + textureStyle.id = 'reader-texture-style'; + document.head.appendChild(textureStyle); + } + textureStyle.textContent = ${appearance.textureOverlayCss.toJsStringLiteral()}; + })(); + """.trimIndent() + } + + private fun pageSectionHtml( + book: SharedEpubBook, + page: ReaderPage, + settings: ReaderSettings, + searchQuery: String, + searchOptions: ReaderSearchOptions, + highlights: List + ): String? { + val chapter = book.chapters.getOrNull(page.chapterIndex) ?: return null + val measuredPageBlocks = page.semanticBlocks + val semanticPageBlocks = measuredPageBlocks.ifEmpty { chapter.semanticBlocks.blocksForPage(page) } + val usedSemanticBlocks = semanticPageBlocks.isNotEmpty() + val blocks = if (usedSemanticBlocks) { + semanticPageBlocks.joinToString("") { it.toHtml(searchQuery, searchOptions) } + } else { + page.text.textToParagraphHtml(searchQuery, searchOptions, baseOffset = page.startOffset) + } + val pageHtml = blocks.applyUserHighlights( + highlights = highlights.filter { it.belongsToPage(page) }, + contentStartOffset = page.startOffset, + contentEndOffset = page.endOffset + ) + logReaderHtml( + "page_document page=${page.pageIndex + 1} chapter=${page.chapterIndex} " + + "range=${page.startOffset}..${page.endOffset} pageText=${page.text.length} " + + "semantic=$usedSemanticBlocks measured=${measuredPageBlocks.isNotEmpty()} " + + "blocks=${semanticPageBlocks.size}/${chapter.semanticBlocks.size} " + + "htmlChars=${pageHtml.length} settingsFont=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + + "summary=\"${semanticPageBlocks.blockSummary()}\" styles=\"${semanticPageBlocks.styleSummary()}\"" + ) + return """ +
+
+ $pageHtml +
+
+ """.trimIndent() + } + private fun document( title: String, settings: ReaderSettings, @@ -144,11 +206,10 @@ object ReaderHtmlDocumentBuilder { pageAnchors: List, readerAiFeaturesEnabled: Boolean, cloudTtsEnabled: Boolean, + externalLookupEnabled: Boolean, textureDataUri: String? ): String { - val bg = settings.backgroundColorArgb?.toCssColor() ?: if (settings.darkMode) "#171A17" else "#FFFCF5" - val fg = settings.textColorArgb?.toCssColor() ?: if (settings.darkMode) "#E7E3D8" else "#24231F" - val highlight = if (settings.darkMode) "#675A00" else "#FFE36E" + val appearance = settings.toDocumentAppearanceCss(textureDataUri) val align = when (settings.textAlign) { SharedReaderTextAlign.START -> "left" SharedReaderTextAlign.JUSTIFY -> "justify" @@ -168,20 +229,21 @@ object ReaderHtmlDocumentBuilder { else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" } } - val textureOverlayCss = settings.textureId - ?.takeIf { settings.textureAlpha > 0.01f } - ?.toTextureOverlayCss(settings.textureAlpha, settings.darkMode, textureDataUri) - .orEmpty() val highlightButtons = highlightPalette.sanitized().colors.joinToString("\n") { color -> - """""" + """""" } val defineButton = if (readerAiFeaturesEnabled) { - """""" + readerSelectionActionButton("define", "Define", ReaderSelectionIconDefinePath) } else { "" } val speakButton = if (cloudTtsEnabled) { - """""" + readerSelectionActionButton("speak", "Speak", ReaderSelectionIconSpeakPath) + } else { + "" + } + val externalLookupButtons = if (externalLookupEnabled) { + readerSelectionActionButton("web-search", "Search", ReaderSelectionIconSearchPath) } else { "" } @@ -198,10 +260,16 @@ object ReaderHtmlDocumentBuilder { $bookCss $customFontCss :root { - color-scheme: ${if (settings.darkMode) "dark" else "light"}; - --reader-bg: $bg; - --reader-fg: $fg; - --reader-highlight: $highlight; + color-scheme: ${appearance.colorScheme}; + --reader-bg: ${appearance.background}; + --reader-fg: ${appearance.foreground}; + --reader-link: ${appearance.linkColors.color}; + --reader-link-decoration: ${appearance.linkColors.decoration}; + --reader-link-bg: ${appearance.linkColors.background}; + --reader-highlight: ${appearance.highlight}; + --reader-scrollbar-track: color-mix(in srgb, var(--reader-bg) 88%, var(--reader-fg)); + --reader-scrollbar-thumb: color-mix(in srgb, var(--reader-fg) 48%, var(--reader-bg)); + --reader-scrollbar-thumb-hover: var(--reader-link); --reader-font-size: ${settings.fontSize}px; --reader-line-height: ${settings.lineSpacing}; --reader-page-width: ${settings.pageWidth}px; @@ -222,13 +290,43 @@ object ReaderHtmlDocumentBuilder { font-size: var(--reader-font-size); line-height: var(--reader-line-height); } + html { + scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track); + scrollbar-width: thin; + } + html::-webkit-scrollbar, + body.reader-vertical::-webkit-scrollbar { + width: 12px; + height: 12px; + } + html::-webkit-scrollbar-track, + body.reader-vertical::-webkit-scrollbar-track { + background: var(--reader-scrollbar-track); + border-radius: 999px; + } + html::-webkit-scrollbar-thumb, + body.reader-vertical::-webkit-scrollbar-thumb { + background: var(--reader-scrollbar-thumb); + border: 3px solid var(--reader-bg); + border-radius: 999px; + } + html::-webkit-scrollbar-thumb:hover, + body.reader-vertical::-webkit-scrollbar-thumb:hover { + background: var(--reader-scrollbar-thumb-hover); + } body { box-sizing: border-box; padding: var(--reader-margin-y) var(--reader-margin-x); overflow-wrap: anywhere; position: relative; } - $textureOverlayCss + body.reader-vertical { + scrollbar-gutter: stable; + } + body.reader-paginated { + height: 100vh; + overflow: hidden; + } .chapter, .page { max-width: var(--reader-page-width); margin: 0 auto 48px; @@ -236,6 +334,30 @@ object ReaderHtmlDocumentBuilder { position: relative; z-index: 1; } + body.reader-paginated .page { + box-sizing: border-box; + height: calc(100vh - (var(--reader-margin-y) * 2)); + margin-bottom: 0; + overflow: hidden; + } + body.reader-paginated .reader-content > :last-child { + margin-bottom: 0 !important; + } + .reader-spread { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 28px; + width: min(100%, calc((var(--reader-page-width) * 2) + 28px)); + height: calc(100vh - (var(--reader-margin-y) * 2)); + margin: 0 auto; + position: relative; + z-index: 1; + } + .reader-spread .page { + width: 100%; + max-width: none; + min-width: 0; + } .chapter-title { text-align: left; font-size: 1.55em; @@ -246,6 +368,10 @@ object ReaderHtmlDocumentBuilder { margin-top: 0; margin-bottom: calc(1em * var(--reader-paragraph-spacing)); } + h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: calc(1em * var(--reader-paragraph-spacing)); + } img, svg, video { max-width: var(--reader-image-scale); height: auto; @@ -262,9 +388,12 @@ object ReaderHtmlDocumentBuilder { color: inherit; border-radius: 2px; } - .reader-user-highlight { - background: color-mix(in srgb, var(--reader-highlight) 72%, transparent); + span[class*="user-highlight-"], + mark.reader-user-highlight { border-radius: 2px; + cursor: pointer; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; } ::highlight(reader-tts-highlight) { background: rgba(125, 211, 252, 0.52); @@ -273,7 +402,7 @@ object ReaderHtmlDocumentBuilder { #reader-tts-highlight-layer { position: absolute; inset: 0; - z-index: 2; + z-index: 3; pointer-events: none; } .reader-tts-highlight-rect { @@ -282,25 +411,23 @@ object ReaderHtmlDocumentBuilder { border-radius: 3px; box-shadow: 0 0 0 1px rgba(14, 116, 144, 0.12); } - ${HighlightColor.entries.joinToString("\n") { ".${it.cssClass} { background: ${it.color.toCssHex()}; }" }} + ${HighlightColor.entries.joinToString("\n") { ".${it.cssClass} { background-color: ${it.color.toCssRgba(0.4f)} !important; }" }} #reader-selection-menu { position: fixed; z-index: 99999; display: none; - gap: 4px; - align-items: center; - flex-wrap: wrap; - max-width: min(560px, calc(100vw - 16px)); - padding: 4px; - border-radius: 8px; + flex-direction: column; + width: max-content; + max-width: min(300px, calc(100vw - 16px)); + padding: 0 0 6px; + border-radius: 14px; background: color-mix(in srgb, var(--reader-bg) 92%, var(--reader-fg)); border: 1px solid color-mix(in srgb, var(--reader-fg) 18%, transparent); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28); + overflow: hidden; } #reader-selection-menu button { border: 0; - border-radius: 6px; - padding: 6px 9px; background: transparent; color: var(--reader-fg); font: 600 12px system-ui, sans-serif; @@ -309,30 +436,154 @@ object ReaderHtmlDocumentBuilder { #reader-selection-menu button:hover { background: color-mix(in srgb, var(--reader-fg) 10%, transparent); } - a { color: inherit; text-decoration-thickness: 0.08em; } + #reader-selection-menu .reader-selection-colors { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--reader-fg) 12%, transparent); + overflow-x: auto; + } + #reader-selection-menu .reader-selection-color { + width: 28px; + height: 28px; + flex: 0 0 auto; + padding: 0; + border-radius: 999px; + background: var(--selection-color); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--reader-fg) 18%, transparent); + } + #reader-selection-menu .reader-selection-actions { + display: grid; + grid-template-columns: repeat(3, 78px); + gap: 4px; + padding: 6px 8px 2px; + } + #reader-selection-menu .reader-selection-action { + min-height: 58px; + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 7px 4px; + line-height: 1; + white-space: nowrap; + } + #reader-selection-menu .reader-selection-icon { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: color-mix(in srgb, var(--reader-fg) 9%, transparent); + color: color-mix(in srgb, var(--reader-fg) 86%, transparent); + } + #reader-selection-menu .reader-selection-icon svg { + width: 18px; + height: 18px; + display: block; + fill: currentColor; + } + .reader-selection-handle { + position: fixed; + z-index: 99998; + display: none; + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + color: #2563eb; + cursor: ew-resize; + touch-action: none; + } + .reader-selection-handle svg { + width: 24px; + height: 24px; + display: block; + fill: currentColor; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28)); + } + .reader-selection-handle-start svg { + transform: rotate(30deg); + transform-origin: 50% 0; + } + .reader-selection-handle-end svg { + transform: rotate(-30deg); + transform-origin: 50% 0; + } + .reader-content a[href], + .reader-content a[href]:link, + .reader-content a[href]:visited, + a[href], + a[href]:link, + a[href]:visited, + a[data-reader-link="true"] { + color: var(--reader-link) !important; + cursor: pointer; + text-decoration-line: underline !important; + text-decoration-color: var(--reader-link-decoration) !important; + text-decoration-thickness: 0.08em; + text-decoration-thickness: max(1px, 0.08em); + text-underline-offset: 0.14em; + text-decoration-skip-ink: auto; + background-image: linear-gradient(transparent 62%, var(--reader-link-bg) 62%); + border-radius: 2px; + } + .reader-content a[href] *, + a[href] *, + a[data-reader-link="true"] * { + color: var(--reader-link) !important; + text-decoration-color: var(--reader-link-decoration) !important; + } + - + $body + + @@ -1296,10 +2518,8 @@ object ReaderHtmlDocumentBuilder { .take(index) .mapNotNull { it.lastTextBlock() } .lastOrNull() - val nextText = asSequence() - .drop(index + 1) - .mapNotNull { it.firstTextBlock() } - .firstOrNull() + val nextText = + asSequence().drop(index + 1).firstNotNullOfOrNull { it.firstTextBlock() } val anchor = previousText?.let { it.startCharOffsetInSource + it.text.length } ?: nextText?.startCharOffsetInSource ?: 0 @@ -1347,12 +2567,12 @@ object ReaderHtmlDocumentBuilder { return when (this) { is SemanticTextBlock -> this is SemanticList -> items.firstOrNull() - is SemanticTable -> rows.asSequence() - .flatMap { it.asSequence() } - .flatMap { it.content.asSequence() } - .mapNotNull { it.firstTextBlock() } - .firstOrNull() - is SemanticFlexContainer -> children.asSequence().mapNotNull { it.firstTextBlock() }.firstOrNull() + is SemanticTable -> rows.asSequence().flatMap { it.asSequence() } + .flatMap { it.content.asSequence() }.firstNotNullOfOrNull { it.firstTextBlock() } + + is SemanticFlexContainer -> children + .firstNotNullOfOrNull { it.firstTextBlock() } + is SemanticWrappingBlock -> paragraphsToWrap.firstOrNull() else -> null } @@ -1365,9 +2585,11 @@ object ReaderHtmlDocumentBuilder { is SemanticTable -> rows.asReversed().asSequence() .flatMap { it.asReversed().asSequence() } .flatMap { it.content.asReversed().asSequence() } - .mapNotNull { it.lastTextBlock() } - .firstOrNull() - is SemanticFlexContainer -> children.asReversed().asSequence().mapNotNull { it.lastTextBlock() }.firstOrNull() + .firstNotNullOfOrNull { it.lastTextBlock() } + + is SemanticFlexContainer -> children.asReversed() + .firstNotNullOfOrNull { it.lastTextBlock() } + is SemanticWrappingBlock -> paragraphsToWrap.lastOrNull() else -> null } @@ -1753,6 +2975,85 @@ object ReaderHtmlDocumentBuilder { return "#$red$green$blue" } + private fun readerLinkCssColors(backgroundArgb: Long, textArgb: Long, darkMode: Boolean): ReaderLinkCssColors { + val backgroundLuminance = backgroundArgb.relativeLuminance() + val textLuminance = textArgb.relativeLuminance() + val candidates = if (darkMode || backgroundLuminance < 0.45f) { + listOf(0xFF7DD3FCL, 0xFF5EEAD4L, 0xFFA5B4FCL, 0xFFFDE68AL, 0xFFFFFFFFL) + } else { + listOf(0xFF005FCCL, 0xFF006D75L, 0xFF7A1E52L, 0xFF4A148CL, 0xFF111827L) + } + val linkColor = candidates.firstOrNull { + it.contrastRatio(backgroundArgb) >= 4.5f && abs(it.relativeLuminance() - textLuminance) >= 0.08f + } ?: candidates.maxByOrNull { it.contrastRatio(backgroundArgb) } ?: if (darkMode) 0xFF7DD3FCL else 0xFF005FCCL + val alpha = if (backgroundLuminance < 0.45f) 0.24f else 0.16f + return ReaderLinkCssColors( + color = linkColor.toCssColor(), + decoration = linkColor.toCssColor(), + background = linkColor.toCssRgba(alpha) + ) + } + + private fun Long.toCssRgba(alpha: Float): String { + val value = this and 0xFFFFFFFFL + val red = (value shr 16) and 0xFF + val green = (value shr 8) and 0xFF + val blue = value and 0xFF + return "rgba($red, $green, $blue, ${alpha.coerceIn(0f, 1f)})" + } + + private fun Long.contrastRatio(other: Long): Float { + val first = relativeLuminance() + val second = other.relativeLuminance() + val lighter = maxOf(first, second) + val darker = minOf(first, second) + return (lighter + 0.05f) / (darker + 0.05f) + } + + private fun Long.relativeLuminance(): Float { + val value = this and 0xFFFFFFFFL + fun channel(shift: Int): Float { + val normalized = (((value shr shift) and 0xFF).toFloat() / 255f) + return if (normalized <= 0.03928f) { + normalized / 12.92f + } else { + ((normalized + 0.055f) / 1.055f).toDouble().pow(2.4).toFloat() + } + } + return 0.2126f * channel(16) + 0.7152f * channel(8) + 0.0722f * channel(0) + } + + private data class ReaderLinkCssColors( + val color: String, + val decoration: String, + val background: String + ) + + private data class ReaderDocumentAppearanceCss( + val background: String, + val foreground: String, + val linkColors: ReaderLinkCssColors, + val highlight: String, + val colorScheme: String, + val textureOverlayCss: String + ) + + private fun ReaderSettings.toDocumentAppearanceCss(textureDataUri: String?): ReaderDocumentAppearanceCss { + val bgArgb = backgroundColorArgb ?: if (darkMode) 0xFF171A17L else 0xFFFFFCF5L + val fgArgb = textColorArgb ?: if (darkMode) 0xFFE7E3D8L else 0xFF24231FL + return ReaderDocumentAppearanceCss( + background = bgArgb.toCssColor(), + foreground = fgArgb.toCssColor(), + linkColors = readerLinkCssColors(bgArgb, fgArgb, darkMode), + highlight = if (darkMode) "#675A00" else "#FFE36E", + colorScheme = if (darkMode) "dark" else "light", + textureOverlayCss = textureId + ?.takeIf { textureAlpha > 0.01f } + ?.toTextureOverlayCss(textureAlpha, darkMode, textureDataUri) + .orEmpty() + ) + } + private fun String.toCssFontUrl(): String { val trimmed = trim() val normalizedInput = trimmed.replace("\\", "/") @@ -1820,6 +3121,30 @@ object ReaderHtmlDocumentBuilder { return replace("\\", "\\\\").replace("'", "\\'") } + private fun String.toJsStringLiteral(): String { + return buildString { + append('"') + this@toJsStringLiteral.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } + append('"') + } + } + private fun String.applyUserHighlights( highlights: List, contentStartOffset: Int, @@ -1836,9 +3161,9 @@ object ReaderHtmlDocumentBuilder { val endIndex = htmlRange.last if (startIndex >= endIndex || endIndex > html.length) return@fold html val markedText = html.substring(startIndex, endIndex) - if (markedText.isBlank()) return@fold html - val marker = """$markedText""" - html.replaceRange(startIndex, endIndex, marker) + if (markedText.visibleHtmlText().isBlank()) return@fold html + val markerStart = """""" + html.replaceRange(startIndex, endIndex, markedText.wrapVisibleHtmlText(markerStart, "")) } return highlights @@ -1846,11 +3171,101 @@ object ReaderHtmlDocumentBuilder { .fold(rangedHtml) { html, highlight -> val text = highlight.text.trim().takeIf { it.isNotBlank() } ?: return@fold html val escapedText = text.escapeHtml() - val markedText = """$escapedText""" + val markedText = """$escapedText""" html.replaceFirst(escapedText, markedText) } } + private fun String.wrapVisibleHtmlText(markerStart: String, markerEnd: String): String { + val output = StringBuilder(length + markerStart.length + markerEnd.length) + var index = 0 + var markerOpen = false + + fun openMarker() { + if (!markerOpen) { + output.append(markerStart) + markerOpen = true + } + } + + fun closeMarker() { + if (markerOpen) { + output.append(markerEnd) + markerOpen = false + } + } + + while (index < length) { + when (this[index]) { + '<' -> { + closeMarker() + val tagEnd = indexOf('>', startIndex = index + 1) + if (tagEnd < 0) { + openMarker() + output.append(this[index]) + index++ + } else { + output.append(substring(index, tagEnd + 1)) + index = tagEnd + 1 + } + } + + '&' -> { + openMarker() + val entityEnd = indexOf(';', startIndex = index + 1) + if (entityEnd > index) { + output.append(substring(index, entityEnd + 1)) + index = entityEnd + 1 + } else { + output.append(this[index]) + index++ + } + } + + else -> { + val nextTag = indexOf('<', startIndex = index).takeIf { it >= 0 } ?: length + val nextEntity = indexOf('&', startIndex = index).takeIf { it >= 0 } ?: length + val nextBoundary = minOf(nextTag, nextEntity) + val textRun = substring(index, nextBoundary) + if (textRun.isBlank()) { + output.append(textRun) + } else { + openMarker() + output.append(textRun) + } + index = nextBoundary + } + } + } + closeMarker() + return output.toString() + } + + private fun String.visibleHtmlText(): String { + val output = StringBuilder(length) + var index = 0 + while (index < length) { + when (this[index]) { + '<' -> { + val tagEnd = indexOf('>', startIndex = index + 1) + index = if (tagEnd < 0) index + 1 else tagEnd + 1 + } + + '&' -> { + output.append('x') + val entityEnd = indexOf(';', startIndex = index + 1) + index = if (entityEnd > index) entityEnd + 1 else index + 1 + } + + else -> { + output.append(this[index]) + index++ + } + } + } + return output.toString() + } + private fun String.htmlRangeForHighlight(highlight: RenderedHighlight): IntRange? { val block = findTextBlockRange(highlight.absoluteStart, highlight.absoluteEnd) if (block != null) { @@ -1942,6 +3357,7 @@ object ReaderHtmlDocumentBuilder { if (boundedEnd <= boundedStart) return null return RenderedHighlight( id = id, + cfi = normalizedLocator.cfi ?: cfi, color = color, absoluteStart = boundedStart, absoluteEnd = boundedEnd, @@ -1994,6 +3410,15 @@ object ReaderHtmlDocumentBuilder { } } + private fun readerSelectionActionButton(action: String, label: String, pathData: String): String { + val safeLabel = label.escapeHtml() + return """""" + } + + private fun readerSelectionSvg(pathData: String): String { + return """""" + } + private data class TextSegment( val text: String, val startOffset: Int @@ -2001,6 +3426,7 @@ object ReaderHtmlDocumentBuilder { private data class RenderedHighlight( val id: String, + val cfi: String, val color: HighlightColor, val absoluteStart: Int, val absoluteEnd: Int, @@ -2019,6 +3445,19 @@ object ReaderHtmlDocumentBuilder { """<([A-Za-z][A-Za-z0-9]*)\b[^>]*\bdata-reader-text-start="(\d+)"[^>]*\bdata-reader-text-end="(\d+)"[^>]*>""" ) + private const val ReaderSelectionIconCopyPath = + "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640L720,160L360,160L360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800L640,800L640,880L200,880Z" + private const val ReaderSelectionIconDefinePath = + "M480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664L840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268L120,664Q155,652 189.5,646Q224,640 260,640Q307,640 351.5,650.5Q396,661 440,682Z" + private const val ReaderSelectionIconSpeakPath = + "M560,828L560,746Q653,719 706.5,642Q760,565 760,466Q760,367 706.5,290Q653,213 560,186L560,104Q687,133 763.5,234Q840,335 840,466Q840,597 763.5,698Q687,799 560,828ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,292Q612,317 646,364.5Q680,412 680,466Q680,520 646,567.5Q612,615 560,640Z" + private const val ReaderSelectionIconSearchPath = + "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" + private const val ReaderSelectionIconClearPath = + "M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" + private const val ReaderSelectionIconTeardropPath = + "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" + private fun String.escapeHtml(): String { return replace("&", "&") .replace("<", "<") @@ -2032,7 +3471,20 @@ object ReaderHtmlDocumentBuilder { return "#${channel(red)}${channel(green)}${channel(blue)}" } + private fun androidx.compose.ui.graphics.Color.toCssRgba(alpha: Float): String { + fun channel(value: Float): Int = (value * 255f).roundToInt().coerceIn(0, 255) + val safeAlpha = alpha.coerceIn(0f, 1f) + return "rgba(${channel(red)}, ${channel(green)}, ${channel(blue)}, ${safeAlpha.formatCssAlpha()})" + } + + private fun Float.formatCssAlpha(): String { + val scaled = (this * 1000f).roundToInt() + val whole = scaled / 1000 + val fraction = (scaled % 1000).toString().padStart(3, '0').trimEnd('0') + return if (fraction.isEmpty()) whole.toString() else "$whole.$fraction" + } + private fun logReaderHtml(message: String) { - println("ReaderHtmlRender $message") + logSharedReaderDiagnostic("ReaderHtmlRender") { message } } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistory.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistory.kt new file mode 100644 index 0000000..18fcff7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistory.kt @@ -0,0 +1,125 @@ +package com.aryan.reader.shared.reader + +data class ReaderJumpHistory( + val locators: List = emptyList(), + val cursor: Int = -1, + val maxEntries: Int = 21 +) { + val backLocator: ReaderLocator? get() = locators.getOrNull(cursor - 1) + val forwardLocator: ReaderLocator? get() = locators.getOrNull(cursor + 1) + val hasJumpTargets: Boolean get() = backLocator != null || forwardLocator != null + + fun record( + currentLocator: ReaderLocator?, + targetLocator: ReaderLocator?, + chapterCount: Int + ): ReaderJumpHistory { + val current = currentLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this + val target = targetLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this + if (current.hasSameJumpLocation(target)) return this + + val pruned = pruned(chapterCount) + val nextLocators = pruned.locators.toMutableList() + var nextCursor = pruned.cursor + + while (nextLocators.lastIndex > nextCursor) { + nextLocators.removeAt(nextLocators.lastIndex) + } + + if (nextCursor > 0 && nextLocators.getOrNull(nextCursor - 1)?.hasSameJumpLocation(current) == true) { + nextLocators[nextCursor] = target + return copy( + locators = nextLocators, + cursor = nextCursor + ).bounded() + } + + if (nextCursor == -1 || nextLocators.getOrNull(nextCursor)?.hasSameJumpLocation(current) != true) { + nextLocators += current + nextCursor = nextLocators.lastIndex + } + + if (nextLocators.lastOrNull()?.hasSameJumpLocation(target) != true) { + nextLocators += target + nextCursor = nextLocators.lastIndex + } + + return copy( + locators = nextLocators, + cursor = nextCursor + ).bounded() + } + + fun pruned(chapterCount: Int): ReaderJumpHistory { + if (chapterCount <= 0) return clear() + val nextLocators = locators.toMutableList() + var nextCursor = cursor + var index = nextLocators.lastIndex + while (index >= 0) { + if (!nextLocators[index].isValidJumpLocator(chapterCount)) { + nextLocators.removeAt(index) + if (nextCursor >= index) nextCursor-- + } + index-- + } + return copy( + locators = nextLocators, + cursor = nextCursor.coerceIn(-1, nextLocators.lastIndex) + ).bounded() + } + + fun stepBack(): ReaderJumpHistory { + return if (backLocator == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0)) + } + + fun stepForward(): ReaderJumpHistory { + return if (forwardLocator == null) this else copy(cursor = (cursor + 1).coerceAtMost(locators.lastIndex)) + } + + fun clear(): ReaderJumpHistory = copy(locators = emptyList(), cursor = -1) + + private fun bounded(): ReaderJumpHistory { + val safeMaxEntries = maxEntries.coerceAtLeast(2) + if (locators.size <= safeMaxEntries) { + return copy(cursor = cursor.coerceIn(-1, locators.lastIndex)) + } + val overflow = locators.size - safeMaxEntries + return copy( + locators = locators.drop(overflow), + cursor = (cursor - overflow).coerceIn(-1, locators.size - overflow - 1) + ) + } +} + +fun ReaderLocator.hasSameJumpLocation(other: ReaderLocator): Boolean { + return jumpLocationKey() == other.jumpLocationKey() +} + +private fun ReaderLocator.isValidJumpLocator(chapterCount: Int): Boolean { + if (chapterCount <= 0) return false + val chapter = chapterIndex + return chapter == null || chapter in 0 until chapterCount +} + +private fun ReaderLocator.jumpLocationKey(): String { + val stableCfi = cfi?.takeIf { it.isNotBlank() } + if (stableCfi != null) { + return listOf( + chapterIndex?.toString().orEmpty(), + chapterId.orEmpty(), + href.orEmpty(), + startOffset?.toString().orEmpty(), + endOffset?.toString().orEmpty(), + stableCfi + ).joinToString("|") + } + return listOf( + chapterIndex?.toString().orEmpty(), + chapterId.orEmpty(), + href.orEmpty(), + pageIndex?.toString().orEmpty(), + startOffset?.toString().orEmpty(), + endOffset?.toString().orEmpty(), + cfi.orEmpty() + ).joinToString("|") +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt index 9e89f65..2933501 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt @@ -11,7 +11,15 @@ data class SharedEpubBook( val title: String, val author: String? = null, val chapters: List, - val css: Map = emptyMap() + val css: Map = emptyMap(), + val tableOfContents: List = emptyList() +) + +data class SharedEpubTocEntry( + val label: String, + val href: String, + val fragmentId: String? = null, + val depth: Int = 0 ) data class SharedEpubChapter( @@ -30,6 +38,11 @@ enum class ReaderReadingMode { VERTICAL } +enum class ReaderPageSpreadMode { + SINGLE, + TWO_PAGE +} + enum class SharedReaderTextAlign { START, JUSTIFY, @@ -41,7 +54,7 @@ data class ReaderSettings( val lineSpacing: Float = 1.45f, val margin: Int = 48, val darkMode: Boolean = false, - val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED, + val readingMode: ReaderReadingMode = ReaderReadingMode.VERTICAL, val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START, val pageWidth: Int = 760, val fontFamily: String = "Default", @@ -58,6 +71,9 @@ data class ReaderSettings( val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT, val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT, val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM, + val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE, + val pdfVerticalPageGapVisible: Boolean = true, + val pdfPageNumberOverlayVisible: Boolean = true, val seamlessChapterNavigation: Boolean = true, val chapterTurnDragMultiplier: Float = 1.0f ) { @@ -65,13 +81,73 @@ data class ReaderSettings( val resolvedVerticalMargin: Int get() = verticalMargin ?: margin } +data class ReaderLayoutSignature( + val fontSize: Int, + val lineSpacing: Float, + val horizontalMargin: Int, + val verticalMargin: Int, + val readingMode: ReaderReadingMode, + val textAlign: SharedReaderTextAlign, + val pageWidth: Int, + val fontFamily: String, + val paragraphSpacing: Float, + val imageScale: Float, + val pageSpreadMode: ReaderPageSpreadMode, + val customFontPath: String? +) + +data class ReaderAppearanceSignature( + val darkMode: Boolean, + val themeId: String?, + val textureId: String?, + val textureAlpha: Float, + val backgroundColorArgb: Long?, + val textColorArgb: Long? +) + +fun ReaderSettings.layoutSignature(): ReaderLayoutSignature { + return ReaderLayoutSignature( + fontSize = fontSize, + lineSpacing = lineSpacing, + horizontalMargin = resolvedHorizontalMargin, + verticalMargin = resolvedVerticalMargin, + readingMode = readingMode, + textAlign = textAlign, + pageWidth = pageWidth, + fontFamily = fontFamily, + paragraphSpacing = paragraphSpacing, + imageScale = imageScale, + pageSpreadMode = pageSpreadMode, + customFontPath = customFontPath + ) +} + +fun ReaderSettings.appearanceSignature(): ReaderAppearanceSignature { + return ReaderAppearanceSignature( + darkMode = darkMode, + themeId = themeId, + textureId = textureId, + textureAlpha = textureAlpha, + backgroundColorArgb = backgroundColorArgb, + textColorArgb = textColorArgb + ) +} + +data class ReaderViewportSpec( + val widthPx: Int, + val heightPx: Int +) { + val isSpecified: Boolean get() = widthPx > 0 && heightPx > 0 +} + data class ReaderPage( val pageIndex: Int, val chapterIndex: Int, val chapterTitle: String, val text: String, val startOffset: Int, - val endOffset: Int + val endOffset: Int, + val semanticBlocks: List = emptyList() ) data class PaginatedReaderState( @@ -81,40 +157,96 @@ data class PaginatedReaderState( val settings: ReaderSettings = ReaderSettings() ) { val currentPage: ReaderPage? get() = pages.getOrNull(currentPageIndex) - val progress: Float get() = if (pages.isEmpty()) 0f else ((currentPageIndex + 1).toFloat() / pages.size) * 100f + val progress: Float + get() { + if (pages.isEmpty()) return 0f + val visibleEnd = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings) + .lastOrNull() + ?: currentPageIndex + return ((visibleEnd + 1).toFloat() / pages.size) * 100f + } val canGoPrevious: Boolean get() = currentPageIndex > 0 - val canGoNext: Boolean get() = currentPageIndex < pages.lastIndex + val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings) + val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings) + val visiblePages: List + get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings) + .mapNotNull { pages.getOrNull(it) } } -object SampleReaderBooks { - fun desktopWelcomeBook(): SharedEpubBook { - return SharedEpubBook( - id = "desktop_welcome", - fileName = "Desktop Welcome.epub", - title = "Episteme Desktop Reader", - author = "Episteme", - chapters = listOf( - SharedEpubChapter( - id = "intro", - title = "A Careful First Page", - plainText = """ - This is the first desktop paginated reader milestone. +object ReaderSpreadLayout { + fun pageStep(settings: ReaderSettings): Int { + return if (settings.isTwoPageSpreadEnabled()) 2 else 1 + } - It intentionally starts with the quiet parts: page state, chapter navigation, font sizing, margins, light and dark reading surfaces, progress, and a JVM EPUB loader. The Android reader remains where it is, which keeps the mobile app protected while Windows grows its own platform layer. + fun normalizePageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int { + if (pageCount <= 0) return 0 + val clamped = pageIndex.coerceIn(0, pageCount - 1) + return if (settings.isTwoPageSpreadEnabled()) { + (clamped - (clamped % 2)).coerceIn(0, pageCount - 1) + } else { + clamped + } + } - The next pieces can be added one by one: persisted locations, bookmarks, highlights, table of contents polish, keyboard shortcuts, and eventually the richer pagination engine from Android once its platform-specific parts are behind interfaces. - """.trimIndent() - ), - SharedEpubChapter( - id = "scope", - title = "What Works Here", - plainText = """ - The desktop shell can import EPUB files and extract readable spine text using the JDK zip APIs. It does not try to render complex CSS, images, MathML, or annotations yet. + fun canGoNext(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Boolean { + if (pageCount <= 1) return false + val current = normalizePageIndex(pageIndex, pageCount, settings) + return current + pageStep(settings) < pageCount + } - That limitation is deliberate. A plain paginated reader gives us a working Windows loop without pulling Android WebView, SAF, Room, PDF, or existing reader screens into the first KMP step. - """.trimIndent() - ) - ) - ) + fun nextPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int { + return normalizePageIndex(pageIndex + pageStep(settings), pageCount, settings) + } + + fun previousPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int { + return normalizePageIndex(pageIndex - pageStep(settings), pageCount, settings) + } + + fun visiblePageIndices(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List { + if (pageCount <= 0) return emptyList() + val start = normalizePageIndex(pageIndex, pageCount, settings) + if (!settings.isTwoPageSpreadEnabled()) return listOf(start) + return listOf(start, start + 1).filter { it in 0 until pageCount } + } + + fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String { + val total = pageCount.coerceAtLeast(1) + val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) } + val first = pages.first() + 1 + val last = pages.last() + 1 + return if (first == last) "$first" else "$first-$last" + } + + fun sliderStepCount(pageCount: Int, settings: ReaderSettings): Int { + val total = pageCount.coerceAtLeast(1) + return if (settings.isTwoPageSpreadEnabled()) { + (total + 1) / 2 + } else { + total + } + } + + fun sliderPositionForPage(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int { + val normalized = normalizePageIndex(pageIndex, pageCount, settings) + val position = if (settings.isTwoPageSpreadEnabled()) { + (normalized / 2) + 1 + } else { + normalized + 1 + } + return position.coerceIn(1, sliderStepCount(pageCount, settings)) + } + + fun pageNumberForSliderPosition(position: Int, pageCount: Int, settings: ReaderSettings): Int { + val clamped = position.coerceIn(1, sliderStepCount(pageCount, settings)) + val pageIndex = if (settings.isTwoPageSpreadEnabled()) { + (clamped - 1) * 2 + } else { + clamped - 1 + } + return normalizePageIndex(pageIndex, pageCount, settings) + 1 } } + +fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean { + return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.kt new file mode 100644 index 0000000..2ca2069 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.kt @@ -0,0 +1,13 @@ +package com.aryan.reader.shared.reader + +internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics" +internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" + +internal expect val SharedReaderDiagnosticsEnabled: Boolean +internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean + +internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) { + if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) { + println("$tag ${message()}") + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt index e02408f..057db4d 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt @@ -7,5 +7,5 @@ import androidx.compose.ui.Modifier internal expect fun LocalBookCoverImage( path: String, contentDescription: String?, - modifier: Modifier = Modifier + modifier: Modifier ) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt index 2cc92ca..9acfc88 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt @@ -4,6 +4,9 @@ import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.FileType import com.aryan.reader.shared.LibraryFilters import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFeaturePolicy +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.ShelfType import com.aryan.reader.shared.isOpdsStream @@ -26,43 +29,48 @@ enum class SharedAppToolAction { data class SharedAppShellModel( val primaryTabs: List, val selectedPrimaryTab: SharedAppTab, - val toolActions: List + val toolActions: List, + val showPrimaryNavigation: Boolean ) fun sharedAppShellModel( selectedTab: SharedAppTab, - aiSettingsAvailable: Boolean + aiSettingsAvailable: Boolean, + featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard ): SharedAppShellModel { - val primaryTabs = listOf( - SharedAppTab.HOME, - SharedAppTab.LIBRARY, - SharedAppTab.CATALOGS, - SharedAppTab.READER - ) + val primaryTabs = buildList { + add(SharedAppTab.HOME) + add(SharedAppTab.LIBRARY) + if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS) + } val selectedPrimaryTab = when (selectedTab) { SharedAppTab.SHELVES -> SharedAppTab.LIBRARY + SharedAppTab.SETTINGS, SharedAppTab.CUSTOM_FONTS, SharedAppTab.SUPPORT, SharedAppTab.FEEDBACK, SharedAppTab.ABOUT -> SharedAppTab.HOME else -> selectedTab - } + }.takeIf { it in primaryTabs } ?: SharedAppTab.HOME val toolActions = buildList { add(SharedAppToolAction.IMPORT_FILES) add(SharedAppToolAction.IMPORT_FOLDER) add(SharedAppToolAction.SYNC) add(SharedAppToolAction.APP_THEME) - if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS) + if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS) add(SharedAppToolAction.CUSTOM_FONTS) - add(SharedAppToolAction.HELP_FEEDBACK) - add(SharedAppToolAction.SUPPORT) + if (featurePolicy.projectLinks) { + add(SharedAppToolAction.HELP_FEEDBACK) + add(SharedAppToolAction.SUPPORT) + } add(SharedAppToolAction.ABOUT) add(SharedAppToolAction.TABS_TOGGLE) } return SharedAppShellModel( primaryTabs = primaryTabs, selectedPrimaryTab = selectedPrimaryTab, - toolActions = toolActions + toolActions = toolActions, + showPrimaryNavigation = selectedTab != SharedAppTab.READER ) } @@ -115,6 +123,50 @@ data class NonReaderLibraryOrganizationModel( val hasOpdsStreams: Boolean ) +internal data class NonReaderLibraryFileTypeGroup( + val title: String, + val fileTypes: List +) + +private val LibraryFileTypeGroupTemplates = listOf( + NonReaderLibraryFileTypeGroup( + title = "Books", + fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2) + ), + NonReaderLibraryFileTypeGroup( + title = "Documents", + fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT) + ), + NonReaderLibraryFileTypeGroup( + title = "Text and web", + fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML) + ), + NonReaderLibraryFileTypeGroup( + title = "Comics", + fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7) + ) +) + +internal fun nonReaderLibraryFileTypeGroups( + platform: ReaderPlatform = ReaderPlatform.DESKTOP +): List { + val readableTypes = SharedFileCapabilities.readableTypesFor(platform) + val knownGroupedTypes = LibraryFileTypeGroupTemplates.flatMapTo(mutableSetOf()) { it.fileTypes } + val grouped = LibraryFileTypeGroupTemplates.mapNotNull { group -> + val visibleTypes = group.fileTypes.filter { it in readableTypes } + group.copy(fileTypes = visibleTypes).takeIf { visibleTypes.isNotEmpty() } + } + val otherTypes = readableTypes + .filterNot { it in knownGroupedTypes } + .sortedBy { it.ordinal } + + return if (otherTypes.isEmpty()) { + grouped + } else { + grouped + NonReaderLibraryFileTypeGroup("Other", otherTypes) + } +} + fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel { val books = rawLibraryBooks val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt index a289ded..7896e0a 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -35,6 +36,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.MenuBook @@ -44,6 +47,7 @@ import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList @@ -53,6 +57,8 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.AssistChip import androidx.compose.material3.Button @@ -61,12 +67,12 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -91,6 +97,7 @@ import com.aryan.reader.shared.IN_APP_STORAGE_SOURCE import com.aryan.reader.shared.LibraryAction import com.aryan.reader.shared.LibraryFilters import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfType @@ -100,6 +107,7 @@ import com.aryan.reader.shared.cardTitle import com.aryan.reader.shared.isOpdsStream import com.aryan.reader.shared.progressPercentValue import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.replaceBookSelectionWithVisibleBooks enum class NonReaderLibraryTab { BOOKS, @@ -112,6 +120,45 @@ enum class NonReaderLibraryTab { COMPLETED } +private val AndroidLibraryTabs = listOf( + NonReaderLibraryTab.BOOKS, + NonReaderLibraryTab.SHELVES, + NonReaderLibraryTab.FOLDERS +) + +internal fun visibleNonReaderLibraryTabs(): List = AndroidLibraryTabs + +private fun NonReaderLibraryTab.visibleLibraryTab(): NonReaderLibraryTab { + return takeIf { it in AndroidLibraryTabs } ?: NonReaderLibraryTab.BOOKS +} + +internal fun SharedReaderScreenState.visibleBooksForLibrarySelection(tab: NonReaderLibraryTab): List { + return when (tab.visibleLibraryTab()) { + NonReaderLibraryTab.BOOKS, + NonReaderLibraryTab.UNREAD, + NonReaderLibraryTab.IN_PROGRESS, + NonReaderLibraryTab.COMPLETED -> libraryBooks + NonReaderLibraryTab.SHELVES -> shelves + .filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART } + .flatMap { it.books } + .distinctBy { it.id } + NonReaderLibraryTab.SMART_SHELVES -> shelves + .filter { it.type == ShelfType.SMART } + .flatMap { it.books } + .distinctBy { it.id } + NonReaderLibraryTab.TAGS -> shelves + .filter { it.type == ShelfType.TAG && it.bookCount > 0 } + .flatMap { it.books } + .distinctBy { it.id } + NonReaderLibraryTab.FOLDERS -> { + val currentFolder = viewingShelfId?.let { id -> shelves.firstOrNull { it.id == id && it.type == ShelfType.FOLDER } } + val folderShelves = currentFolder?.let(::listOf) + ?: shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null } + folderShelves.flatMap { it.books }.distinctBy { it.id } + } + } +} + private enum class BookViewMode { COVERS, LIST @@ -135,6 +182,8 @@ fun SharedHomeScreen( onCloseAllTabs: () -> Unit = {}, onRecentLimitChange: (Int) -> Unit = {}, onTogglePinned: (BookItem) -> Unit = {}, + onOpenSettings: () -> Unit = {}, + showActiveTabs: Boolean = true, modifier: Modifier = Modifier ) { val model = state.toNonReaderHomeLayoutModel() @@ -144,19 +193,24 @@ fun SharedHomeScreen( modifier = modifier, trailing = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Settings") + } RecentLimitMenu( currentLimit = state.recentFilesLimit, onRecentLimitChange = onRecentLimitChange ) OutlinedButton(onClick = onImportFolder) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Folder") + Text("Add folder") } Button(onClick = onImportBooks) { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Import") + Text("Import files") } } } @@ -181,16 +235,24 @@ fun SharedHomeScreen( } if (model.isEmpty) { - SharedEmptyState( - icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) }, - title = if (model.isLibraryEmpty) "Your library is empty" else "No recent files", - body = if (model.isLibraryEmpty) "Import books or connect a folder to start building your desktop library." else "Open books from the library and they will appear here.", - actionLabel = "Import books", - onAction = onImportBooks, - secondaryActionLabel = "Import folder", - onSecondaryAction = onImportFolder, - modifier = Modifier.weight(1f) - ) + if (model.isLibraryEmpty) { + LibraryImportEmptyState( + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + modifier = Modifier.weight(1f) + ) + } else { + SharedEmptyState( + icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = "No recent files", + body = "Open books from the library and they will appear here.", + actionLabel = "Import files", + onAction = onImportBooks, + secondaryActionLabel = "Add folder", + onSecondaryAction = onImportFolder, + modifier = Modifier.weight(1f) + ) + } } else { LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -209,7 +271,7 @@ fun SharedHomeScreen( ) } } - if (state.isTabsEnabled && model.activeTabs.isNotEmpty()) { + if (showActiveTabs && state.isTabsEnabled && model.activeTabs.isNotEmpty()) { item(key = "tabs") { ActiveTabStrip( openTabs = model.activeTabs, @@ -276,21 +338,19 @@ fun SharedLibraryScreen( onTagSelectedBooks: () -> Unit = {}, onAddSelectedBooksToShelf: () -> Unit = {}, onImportFolder: () -> Unit = {}, + onSyncFolderMetadata: () -> Unit = {}, + onScanFolders: () -> Unit = {}, onTogglePinned: (BookItem) -> Unit = {}, + useImportEmptyStateWhenLibraryEmpty: Boolean = false, modifier: Modifier = Modifier ) { val organization = state.toNonReaderLibraryOrganizationModel() + val activeLibraryTab = selectedTab.visibleLibraryTab() var showFilters by remember { mutableStateOf(false) } var viewMode by remember { mutableStateOf(BookViewMode.COVERS) } fun selectLibraryTab(tab: NonReaderLibraryTab) { - onTabChange(tab) - val status = tab.readStatusFilter() - if (status != null) { - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(readStatus = status)))) - } else if (selectedTab.readStatusFilter() != null) { - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(readStatus = ReadStatusFilter.ALL)))) - } + onTabChange(tab.visibleLibraryTab()) } NonReaderScreenScaffold( @@ -301,12 +361,19 @@ fun SharedLibraryScreen( if (state.selectedBookIds.isNotEmpty()) { val selectedBooks = state.rawLibraryBooks.filter { it.id in state.selectedBookIds } val allSelectedPinned = selectedBooks.isNotEmpty() && selectedBooks.all { it.id in state.pinnedLibraryBookIds } + val visibleSelectionBooks = state.visibleBooksForLibrarySelection(activeLibraryTab) + val allVisibleSelected = visibleSelectionBooks.isNotEmpty() && + state.selectedBookIds.containsAll(visibleSelectionBooks.map { it.id }) SelectionToolbar( count = state.selectedBookIds.size, onClear = onClearSelection, onRemove = onRemoveSelected, onTag = onTagSelectedBooks, onAddToShelf = onAddSelectedBooksToShelf, + onSelectAll = { + onStateChange(state.replaceBookSelectionWithVisibleBooks(visibleSelectionBooks)) + }, + selectAllLabel = if (allVisibleSelected) "Clear visible" else "Select visible", onPin = { selectedBooks .filter { book -> allSelectedPinned || book.id !in state.pinnedLibraryBookIds } @@ -317,17 +384,67 @@ fun SharedLibraryScreen( ) } - BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { - val useSidebar = maxWidth >= 980.dp - if (useSidebar) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { - LibraryOrganizationSidebar( - organization = organization, - selectedTab = selectedTab, - onTabSelected = ::selectLibraryTab, - modifier = Modifier.width(232.dp).fillMaxHeight() - ) - Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (useImportEmptyStateWhenLibraryEmpty && state.rawLibraryBooks.isEmpty()) { + LibraryImportEmptyState( + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + modifier = Modifier.weight(1f) + ) + } else { + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + val useSidebar = maxWidth >= 980.dp + if (useSidebar) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + LibraryOrganizationSidebar( + organization = organization, + selectedTab = activeLibraryTab, + onTabSelected = ::selectLibraryTab, + modifier = Modifier.width(232.dp).fillMaxHeight() + ) + Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LibraryToolbar( + state = state, + viewMode = viewMode, + showFilters = showFilters, + onViewModeChange = { viewMode = it }, + onToggleFilters = { showFilters = !showFilters }, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf + ) + LibraryContent( + state = state, + selectedTab = activeLibraryTab, + viewMode = viewMode, + showFilters = showFilters, + onStateChange = onStateChange, + onTabChange = ::selectLibraryTab, + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + onSyncFolderMetadata = onSyncFolderMetadata, + onScanFolders = onScanFolders, + modifier = Modifier.weight(1f) + ) + } + } + } else { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LibraryTabStrip( + organization = organization, + selectedTab = activeLibraryTab, + onTabSelected = ::selectLibraryTab + ) LibraryToolbar( state = state, viewMode = viewMode, @@ -342,12 +459,14 @@ fun SharedLibraryScreen( ) LibraryContent( state = state, - selectedTab = selectedTab, + selectedTab = activeLibraryTab, viewMode = viewMode, showFilters = showFilters, - organization = organization, onStateChange = onStateChange, + onTabChange = ::selectLibraryTab, onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty, onOpenBook = onOpenBook, onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, @@ -356,48 +475,12 @@ fun SharedLibraryScreen( onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, onRemoveFolder = onRemoveFolder, + onSyncFolderMetadata = onSyncFolderMetadata, + onScanFolders = onScanFolders, modifier = Modifier.weight(1f) ) } } - } else { - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - LibraryTabStrip( - organization = organization, - selectedTab = selectedTab, - onTabSelected = ::selectLibraryTab - ) - LibraryToolbar( - state = state, - viewMode = viewMode, - showFilters = showFilters, - onViewModeChange = { viewMode = it }, - onToggleFilters = { showFilters = !showFilters }, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onImportFolder = onImportFolder, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf - ) - LibraryContent( - state = state, - selectedTab = selectedTab, - viewMode = viewMode, - showFilters = showFilters, - organization = organization, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onOpenBook = onOpenBook, - onToggleSelection = onToggleSelection, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - modifier = Modifier.weight(1f) - ) - } } } } @@ -426,11 +509,6 @@ fun SharedShelvesScreen( modifier = modifier, trailing = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = onCreateSmartShelf) { - Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Smart") - } Button(onClick = onCreateShelf) { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) @@ -559,6 +637,7 @@ private fun HomeBookShelf( book = book, selected = book.id in selectedBookIds, pinned = book.id in pinnedBookIds, + selectionModeActive = selectedBookIds.isNotEmpty(), onOpen = { onOpenBook(book) }, onToggleSelection = { onToggleSelection(book.id) }, onShowInfo = { onShowBookInfo(book) }, @@ -578,6 +657,8 @@ private fun SelectionToolbar( onRemove: () -> Unit, onTag: () -> Unit = {}, onAddToShelf: () -> Unit = {}, + onSelectAll: (() -> Unit)? = null, + selectAllLabel: String = "Select visible", onPin: (() -> Unit)? = null, pinLabel: String = "Pin", onInfo: (() -> Unit)? = null @@ -622,6 +703,13 @@ private fun SelectionToolbar( Spacer(Modifier.width(6.dp)) Text("Shelf") } + onSelectAll?.let { selectAll -> + TextButton(onClick = selectAll) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(selectAllLabel) + } + } TextButton(onClick = onClear) { Text("Clear") } @@ -745,23 +833,17 @@ private fun LibraryOrganizationSidebar( modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) ) } - item { LibraryNavItem(Icons.Default.Book, "Books", organization.allBooksCount, selectedTab == NonReaderLibraryTab.BOOKS, { onTabSelected(NonReaderLibraryTab.BOOKS) }) } - item { LibraryNavItem(Icons.AutoMirrored.Filled.LibraryBooks, "Shelves", organization.shelfCount, selectedTab == NonReaderLibraryTab.SHELVES, { onTabSelected(NonReaderLibraryTab.SHELVES) }) } - item { LibraryNavItem(Icons.Default.FilterList, "Smart", organization.smartShelfCount, selectedTab == NonReaderLibraryTab.SMART_SHELVES, { onTabSelected(NonReaderLibraryTab.SMART_SHELVES) }) } - item { LibraryNavItem(Icons.Default.Tag, "Tags", organization.tagCount, selectedTab == NonReaderLibraryTab.TAGS, { onTabSelected(NonReaderLibraryTab.TAGS) }) } - item { LibraryNavItem(Icons.Default.Folder, "Folders", organization.folderCount, selectedTab == NonReaderLibraryTab.FOLDERS, { onTabSelected(NonReaderLibraryTab.FOLDERS) }) } - item { - Text( - "Reading", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp) - ) + visibleNonReaderLibraryTabs().forEach { tab -> + item { + LibraryNavItem( + icon = tab.icon, + label = tab.label, + count = tab.count(organization), + selected = selectedTab == tab, + onClick = { onTabSelected(tab) } + ) + } } - item { LibraryNavItem(Icons.Default.Book, "Unread", organization.unreadCount, selectedTab == NonReaderLibraryTab.UNREAD, { onTabSelected(NonReaderLibraryTab.UNREAD) }) } - item { LibraryNavItem(Icons.AutoMirrored.Filled.MenuBook, "In progress", organization.inProgressCount, selectedTab == NonReaderLibraryTab.IN_PROGRESS, { onTabSelected(NonReaderLibraryTab.IN_PROGRESS) }) } - item { LibraryNavItem(Icons.Default.Check, "Complete", organization.completedCount, selectedTab == NonReaderLibraryTab.COMPLETED, { onTabSelected(NonReaderLibraryTab.COMPLETED) }) } } } } @@ -777,7 +859,7 @@ private fun LibraryTabStrip( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - NonReaderLibraryTab.entries.forEach { tab -> + visibleNonReaderLibraryTabs().forEach { tab -> FilterChip( selected = selectedTab == tab, onClick = { onTabSelected(tab) }, @@ -829,7 +911,7 @@ private fun LibraryToolbar( onCreateSmartShelf: () -> Unit ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( + SharedStableOutlinedTextField( value = state.searchQuery, onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, @@ -873,20 +955,15 @@ private fun LibraryToolbar( Spacer(Modifier.width(8.dp)) Text("Shelf") } - OutlinedButton(onClick = onCreateSmartShelf) { - Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Smart") - } OutlinedButton(onClick = onImportFolder) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Folder") + Text("Add folder") } Button(onClick = onImportBooks) { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Import") + Text("Import files") } } } @@ -898,9 +975,11 @@ private fun LibraryContent( selectedTab: NonReaderLibraryTab, viewMode: BookViewMode, showFilters: Boolean, - organization: NonReaderLibraryOrganizationModel, onStateChange: (SharedReaderScreenState) -> Unit, + onTabChange: (NonReaderLibraryTab) -> Unit = {}, onImportBooks: () -> Unit, + onImportFolder: () -> Unit, + useImportEmptyStateWhenLibraryEmpty: Boolean = false, onOpenBook: (BookItem) -> Unit, onToggleSelection: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, @@ -909,13 +988,14 @@ private fun LibraryContent( onRenameShelf: (Shelf) -> Unit, onDeleteShelf: (Shelf) -> Unit, onRemoveFolder: (Shelf) -> Unit, + onSyncFolderMetadata: () -> Unit, + onScanFolders: () -> Unit, modifier: Modifier = Modifier ) { Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { if (showFilters) { LibraryFilterPanel( state = state, - organization = organization, onStateChange = onStateChange ) } else if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { @@ -929,20 +1009,28 @@ private fun LibraryContent( NonReaderLibraryTab.COMPLETED -> { val books = state.libraryBooks if (books.isEmpty()) { - SharedEmptyState( - icon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(56.dp)) }, - title = if (state.rawLibraryBooks.isEmpty()) "Your library is empty" else "No books match", - body = if (state.rawLibraryBooks.isEmpty()) "Import books to begin building your desktop library." else "Adjust search, sort, or filters to see more books.", - actionLabel = if (state.rawLibraryBooks.isEmpty()) "Import books" else "Clear filters", - onAction = { - if (state.rawLibraryBooks.isEmpty()) { - onImportBooks() - } else { - onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) - } - }, - modifier = Modifier.weight(1f) - ) + if (state.rawLibraryBooks.isEmpty() && useImportEmptyStateWhenLibraryEmpty) { + LibraryImportEmptyState( + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + modifier = Modifier.weight(1f) + ) + } else { + SharedEmptyState( + icon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = if (state.rawLibraryBooks.isEmpty()) "Your library is empty" else "No books match", + body = if (state.rawLibraryBooks.isEmpty()) "Import files into app storage or add a folder from the toolbar." else "Adjust search, sort, or filters to see more books.", + actionLabel = if (state.rawLibraryBooks.isEmpty()) "Import files" else "Clear filters", + onAction = { + if (state.rawLibraryBooks.isEmpty()) { + onImportBooks() + } else { + onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) + } + }, + modifier = Modifier.weight(1f) + ) + } } else { BookGrid( books = books, @@ -959,22 +1047,43 @@ private fun LibraryContent( } } - NonReaderLibraryTab.SHELVES -> ShelfCollection( - shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART }, - selectedBookIds = state.selectedBookIds, - pinnedBookIds = state.pinnedLibraryBookIds, - onOpenBook = onOpenBook, - onToggleSelection = onToggleSelection, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - emptyTitle = "No shelves yet", - emptyBody = "Manual shelves and series collections will appear here.", - modifier = Modifier.weight(1f) - ) + NonReaderLibraryTab.SHELVES -> { + val tagShelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { + BrowseByTagRow( + tagShelves = tagShelves, + onTagShelfSelected = { shelf -> + val tagId = shelf.id.removePrefix("tag_").takeIf { it.isNotBlank() } + if (tagId != null) { + onStateChange( + state.reduce( + LibraryAction.FiltersChanged( + state.libraryFilters.copy(tagIds = setOf(tagId)) + ) + ) + ) + onTabChange(NonReaderLibraryTab.BOOKS) + } + } + ) + ShelfCollection( + shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART }, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + emptyTitle = "No shelves yet", + emptyBody = "Manual shelves and series collections will appear here.", + modifier = Modifier.weight(1f) + ) + } + } NonReaderLibraryTab.SMART_SHELVES -> ShelfCollection( shelves = state.shelves.filter { it.type == ShelfType.SMART }, @@ -1006,20 +1115,77 @@ private fun LibraryContent( modifier = Modifier.weight(1f) ) - NonReaderLibraryTab.FOLDERS -> ShelfCollection( - shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }, - selectedBookIds = state.selectedBookIds, - pinnedBookIds = state.pinnedLibraryBookIds, - onOpenBook = onOpenBook, - onToggleSelection = onToggleSelection, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onRemoveFolder = onRemoveFolder, - emptyTitle = "No folders yet", - emptyBody = "Imported folder metadata will appear here when available.", - modifier = Modifier.weight(1f) - ) + NonReaderLibraryTab.FOLDERS -> { + val currentFolder = state.viewingShelfId + ?.let { id -> state.shelves.firstOrNull { it.id == id && it.type == ShelfType.FOLDER } } + if (currentFolder != null) { + FolderShelfDetail( + shelf = currentFolder, + childShelves = currentFolder.childShelfIds.mapNotNull { childId -> + state.shelves.firstOrNull { it.id == childId } + }, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) }, + onBack = { onStateChange(state.copy(viewingShelfId = currentFolder.parentShelfId)) }, + modifier = Modifier.weight(1f) + ) + } else { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.syncedFolders.isNotEmpty()) { + FolderSyncActionRow( + onSyncFolderMetadata = onSyncFolderMetadata, + onScanFolders = onScanFolders + ) + } + ShelfCollection( + shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRemoveFolder = onRemoveFolder, + onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) }, + emptyTitle = "No folders yet", + emptyBody = "Add a folder to read files from that folder in place.", + modifier = Modifier.weight(1f) + ) + } + } + } + + else -> Unit + } + } +} + +@Composable +private fun FolderSyncActionRow( + onSyncFolderMetadata: () -> Unit, + onScanFolders: () -> Unit +) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onSyncFolderMetadata) { + Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Sync metadata") + } + Button(onClick = onScanFolders) { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Full scan") } } } @@ -1044,7 +1210,15 @@ private fun LibraryFilterSummary( if (state.libraryFilters.fileTypes.isNotEmpty()) { AssistChip( onClick = { onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = emptySet())))) }, - label = { Text("Types: ${state.libraryFilters.fileTypes.joinToString { it.name }}") }, + label = { + Text( + "Types: ${ + state.libraryFilters.fileTypes + .sortedBy { it.ordinal } + .joinToString { SharedFileCapabilities.displayNameFor(it) } + }" + ) + }, trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear file types", modifier = Modifier.size(16.dp)) } ) } @@ -1079,7 +1253,6 @@ private fun LibraryFilterSummary( @OptIn(ExperimentalLayoutApi::class) private fun LibraryFilterPanel( state: SharedReaderScreenState, - organization: NonReaderLibraryOrganizationModel, onStateChange: (SharedReaderScreenState) -> Unit ) { Surface( @@ -1087,89 +1260,141 @@ private fun LibraryFilterPanel( color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) ) { - Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Filters", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(Modifier.weight(1f)) + Text("Filters", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) { Text("Clear") } } } - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - organization.availableFileTypes.forEach { type -> - FilterChip( - selected = type in state.libraryFilters.fileTypes, - onClick = { - val updated = if (type in state.libraryFilters.fileTypes) state.libraryFilters.fileTypes - type else state.libraryFilters.fileTypes + type - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated)))) - }, - label = { Text(type.name) } - ) + + LibraryFilterSection(title = "File type") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + nonReaderLibraryFileTypeGroups().forEach { group -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + group.title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + group.fileTypes.forEach { type -> + FilterChip( + selected = type in state.libraryFilters.fileTypes, + onClick = { + val updated = state.libraryFilters.fileTypes.toggle(type) + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated)))) + }, + label = { Text(SharedFileCapabilities.displayNameFor(type)) } + ) + } + } + } + } } - if (organization.hasInAppBooks) { + } + + LibraryFilterSection(title = "Source folder") { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { FilterChip( selected = IN_APP_STORAGE_SOURCE in state.libraryFilters.sourceFolders, onClick = { - val updated = if (IN_APP_STORAGE_SOURCE in state.libraryFilters.sourceFolders) { - state.libraryFilters.sourceFolders - IN_APP_STORAGE_SOURCE - } else { - state.libraryFilters.sourceFolders + IN_APP_STORAGE_SOURCE - } + val updated = state.libraryFilters.sourceFolders.toggle(IN_APP_STORAGE_SOURCE) onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated)))) }, label = { Text("In-app") } ) + state.syncedFolders.forEach { folder -> + FilterChip( + selected = folder.uriString in state.libraryFilters.sourceFolders, + onClick = { + val updated = state.libraryFilters.sourceFolders.toggle(folder.uriString) + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated)))) + }, + leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(folder.name) } + ) + } } - state.syncedFolders.forEach { folder -> - FilterChip( - selected = folder.uriString in state.libraryFilters.sourceFolders, - onClick = { - val updated = if (folder.uriString in state.libraryFilters.sourceFolders) { - state.libraryFilters.sourceFolders - folder.uriString - } else { - state.libraryFilters.sourceFolders + folder.uriString - } - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated)))) - }, - leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) }, - label = { Text(folder.name) } - ) - } - ReadStatusFilter.entries.filterNot { it == ReadStatusFilter.ALL }.forEach { status -> - FilterChip( - selected = state.libraryFilters.readStatus == status, - onClick = { - onStateChange( - state.reduce( - LibraryAction.FiltersChanged( - state.libraryFilters.copy( - readStatus = if (state.libraryFilters.readStatus == status) ReadStatusFilter.ALL else status + } + + LibraryFilterSection(title = "Read status") { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReadStatusFilter.entries.forEach { status -> + FilterChip( + selected = state.libraryFilters.readStatus == status, + onClick = { + onStateChange( + state.reduce( + LibraryAction.FiltersChanged( + state.libraryFilters.copy(readStatus = status) ) ) ) - ) - }, - label = { Text(status.label) } - ) + }, + label = { Text(status.label) } + ) + } } - state.allTags.forEach { tag -> - FilterChip( - selected = tag.id in state.libraryFilters.tagIds, - onClick = { - val updated = if (tag.id in state.libraryFilters.tagIds) state.libraryFilters.tagIds - tag.id else state.libraryFilters.tagIds + tag.id - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated)))) - }, - leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, - label = { Text(tag.name) } - ) + } + + if (state.allTags.isNotEmpty()) { + LibraryFilterSection(title = "Tags") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + state.allTags.forEach { tag -> + FilterChip( + selected = tag.id in state.libraryFilters.tagIds, + onClick = { + val updated = state.libraryFilters.tagIds.toggle(tag.id) + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated)))) + }, + leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(tag.name) } + ) + } + } } } } } } +@Composable +private fun LibraryFilterSection( + title: String, + content: @Composable () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + content() + } +} + +private fun Set.toggle(value: T): Set { + return if (value in this) this - value else this + value +} + @Composable @OptIn(ExperimentalFoundationApi::class) private fun BookGrid( @@ -1195,6 +1420,7 @@ private fun BookGrid( book = book, selected = book.id in selectedBookIds, pinned = book.id in pinnedBookIds, + selectionModeActive = selectedBookIds.isNotEmpty(), onOpen = { onOpenBook(book) }, onToggleSelection = { onToggleSelection(book.id) }, onShowInfo = { onShowBookInfo(book) }, @@ -1216,6 +1442,7 @@ private fun BookGrid( book = book, selected = book.id in selectedBookIds, pinned = book.id in pinnedBookIds, + selectionModeActive = selectedBookIds.isNotEmpty(), onOpen = { onOpenBook(book) }, onToggleSelection = { onToggleSelection(book.id) }, onShowInfo = { onShowBookInfo(book) }, @@ -1233,6 +1460,7 @@ private fun BookTile( book: BookItem, selected: Boolean, pinned: Boolean, + selectionModeActive: Boolean, onOpen: () -> Unit, onToggleSelection: () -> Unit, onShowInfo: () -> Unit, @@ -1247,7 +1475,12 @@ private fun BookTile( shape = RoundedCornerShape(8.dp), modifier = modifier .fillMaxWidth() - .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection) + .combinedClickable( + onClick = { + if (selectionModeActive) onToggleSelection() else onOpen() + }, + onLongClick = onToggleSelection + ) ) { Column { Box { @@ -1312,6 +1545,7 @@ private fun BookListItem( book: BookItem, selected: Boolean, pinned: Boolean, + selectionModeActive: Boolean, onOpen: () -> Unit, onToggleSelection: () -> Unit, onShowInfo: () -> Unit, @@ -1322,7 +1556,12 @@ private fun BookListItem( Surface( modifier = Modifier .fillMaxWidth() - .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection), + .combinedClickable( + onClick = { + if (selectionModeActive) onToggleSelection() else onOpen() + }, + onLongClick = onToggleSelection + ), shape = RoundedCornerShape(8.dp), color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) @@ -1533,6 +1772,35 @@ private fun ProgressSection(progressPercentage: Float?) { } } +@Composable +private fun BrowseByTagRow( + tagShelves: List, + onTagShelfSelected: (Shelf) -> Unit +) { + if (tagShelves.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Browse by tag", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + tagShelves.forEach { shelf -> + FilterChip( + selected = false, + onClick = { onTagShelfSelected(shelf) }, + leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(shelf.name) } + ) + } + } + } +} + @Composable private fun ShelfCollection( shelves: List, @@ -1546,6 +1814,7 @@ private fun ShelfCollection( onRenameShelf: (Shelf) -> Unit = {}, onDeleteShelf: (Shelf) -> Unit = {}, onRemoveFolder: (Shelf) -> Unit = {}, + onOpenShelf: ((Shelf) -> Unit)? = null, emptyTitle: String, emptyBody: String, modifier: Modifier = Modifier @@ -1577,7 +1846,8 @@ private fun ShelfCollection( onTogglePinned = onTogglePinned, onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder + onRemoveFolder = onRemoveFolder, + onOpenShelf = onOpenShelf ) } } @@ -1595,7 +1865,8 @@ private fun ShelfSection( onTogglePinned: (BookItem) -> Unit, onRenameShelf: (Shelf) -> Unit, onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit + onRemoveFolder: (Shelf) -> Unit, + onOpenShelf: ((Shelf) -> Unit)? ) { Surface( shape = RoundedCornerShape(8.dp), @@ -1604,21 +1875,36 @@ private fun ShelfSection( ) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - CollectionCoverStack(shelf) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = shelf.type.icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val openShelf = onOpenShelf + Row( + modifier = Modifier + .weight(1f) + .then(if (openShelf != null) Modifier.clickable { openShelf(shelf) } else Modifier), + verticalAlignment = Alignment.CenterVertically + ) { + CollectionCoverStack(shelf) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = shelf.type.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(shelf.subtitleLabel(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (openShelf != null) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Open folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Text("${shelf.bookCount} books", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - if ((shelf.type == ShelfType.MANUAL || shelf.type == ShelfType.SMART) && shelf.id != "unshelved") { + if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") { IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(34.dp)) { Icon(Icons.Default.Edit, contentDescription = "Rename shelf", modifier = Modifier.size(18.dp)) } @@ -1638,6 +1924,7 @@ private fun ShelfSection( book = book, selected = book.id in selectedBookIds, pinned = book.id in pinnedBookIds, + selectionModeActive = selectedBookIds.isNotEmpty(), onOpen = { onOpenBook(book) }, onToggleSelection = { onToggleSelection(book.id) }, onShowInfo = { onShowBookInfo(book) }, @@ -1652,8 +1939,219 @@ private fun ShelfSection( } } +@Composable +private fun FolderShelfDetail( + shelf: Shelf, + childShelves: List, + selectedBookIds: Set, + pinnedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, + onOpenShelf: (Shelf) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(shelf.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(shelf.subtitleLabel(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + if (childShelves.isEmpty() && shelf.directBooks.isEmpty()) { + SharedEmptyState( + icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = "Folder is empty", + body = "No supported files or subfolders are available here.", + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (childShelves.isNotEmpty()) { + item(key = "folders_header") { + SectionLabel("Folders") + } + items(childShelves, key = { it.id }) { childShelf -> + FolderShelfListItem( + shelf = childShelf, + onOpenShelf = { onOpenShelf(childShelf) } + ) + } + } + if (shelf.directBooks.isNotEmpty()) { + item(key = "files_header") { + SectionLabel("Files") + } + items(shelf.directBooks, key = { it.id }) { book -> + BookListItem( + book = book, + selected = book.id in selectedBookIds, + pinned = book.id in pinnedBookIds, + selectionModeActive = selectedBookIds.isNotEmpty(), + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) } + ) + } + } + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun FolderShelfListItem( + shelf: Shelf, + onOpenShelf: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenShelf), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CollectionCoverStack(shelf) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(shelf.subtitleLabel(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Open folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private fun Shelf.subtitleLabel(): String { + if (type != ShelfType.FOLDER) return bookCountLabel(bookCount) + val parts = buildList { + if (childShelfCount > 0) add(folderCountLabel(childShelfCount)) + if (directBookCount > 0) add(fileCountLabel(directBookCount)) + } + return parts.ifEmpty { listOf(bookCountLabel(bookCount)) }.joinToString(", ") +} + +private fun bookCountLabel(count: Int): String { + return "$count ${if (count == 1) "book" else "books"}" +} + +private fun folderCountLabel(count: Int): String { + return "$count ${if (count == 1) "folder" else "folders"}" +} + +private fun fileCountLabel(count: Int): String { + return "$count ${if (count == 1) "file" else "files"}" +} + @Composable private fun CollectionCoverStack(shelf: Shelf) { + val booksForCovers = collectionCoverStackBooks(shelf) + if (booksForCovers.isEmpty()) { + EmptyCollectionCoverStack(shelf) + return + } + + val coverWidth = 38.dp + val coverHeight = 56.dp + val horizontalOffset = 7.dp + val stackWidth = coverWidth + (horizontalOffset * (booksForCovers.size - 1)) + + Box( + modifier = Modifier.size(width = 54.dp, height = 66.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(stackWidth) + .height(coverHeight) + ) { + booksForCovers.forEachIndexed { index, book -> + CollectionCoverBook( + book = book, + contentDescription = if (booksForCovers.size == 1) shelf.name else null, + modifier = Modifier + .size(width = coverWidth, height = coverHeight) + .align(Alignment.CenterEnd) + .offset(x = -horizontalOffset * index) + ) + } + } + } +} + +@Composable +private fun CollectionCoverBook( + book: BookItem, + contentDescription: String?, + modifier: Modifier = Modifier +) { + val coverPath = book.coverImagePath?.takeIf { it.isNotBlank() } + Surface( + modifier = modifier, + color = fileTypeColor(book.type), + contentColor = Color.White, + shape = RoundedCornerShape(7.dp), + shadowElevation = 3.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Book, contentDescription = null, modifier = Modifier.size(18.dp)) + if (coverPath != null) { + LocalBookCoverImage( + path = coverPath, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize() + ) + } + } + } +} + +@Composable +private fun EmptyCollectionCoverStack(shelf: Shelf) { Box(Modifier.size(width = 54.dp, height = 66.dp)) { val colors = listOf( MaterialTheme.colorScheme.primary.copy(alpha = 0.28f), @@ -1675,6 +2173,17 @@ private fun CollectionCoverStack(shelf: Shelf) { } } +internal fun collectionCoverStackBooks(shelf: Shelf): List { + val booksForCovers = shelf.books.take(CollectionCoverStackBookLimit).reversed() + return if (booksForCovers.size <= 1) { + listOfNotNull(shelf.topBook) + } else { + booksForCovers + } +} + +private const val CollectionCoverStackBookLimit = 4 + @Composable private fun SortMenu( sortOrder: SortOrder, @@ -1706,6 +2215,24 @@ private fun SortMenu( } } +@Composable +private fun LibraryImportEmptyState( + onImportBooks: () -> Unit, + onImportFolder: () -> Unit, + modifier: Modifier = Modifier +) { + SharedEmptyState( + icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = "Your library is empty", + body = "Import files into app storage or add a folder to read files in place.", + actionLabel = "Import files", + onAction = onImportBooks, + secondaryActionLabel = "Add folder", + onSecondaryAction = onImportFolder, + modifier = modifier + ) +} + @Composable private fun SharedEmptyState( icon: @Composable () -> Unit, @@ -1841,7 +2368,7 @@ private fun fileTypeColor(type: FileType): Color { return when (type) { FileType.PDF -> Color(0xFF9C4146) FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C) - FileType.DOCX, FileType.ODT, FileType.FODT -> Color(0xFF0F52BA) + FileType.DOCX, FileType.ODT, FileType.FODT, FileType.PPTX -> Color(0xFF0F52BA) FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49) else -> Color(0xFF5D6B82) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderContentRenderPlan.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderContentRenderPlan.kt new file mode 100644 index 0000000..33c8e66 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderContentRenderPlan.kt @@ -0,0 +1,48 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderHighlightPalette +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSearchOptions +import com.aryan.reader.shared.reader.ReaderSettings + +data class ReaderContentNavigationTarget( + val locator: ReaderLocator?, + val requestId: Long, + val readingMode: ReaderReadingMode, + val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(), + val ttsLocator: ReaderLocator? = null, + val ttsRequestId: Long = 0L +) + +sealed interface ReaderContentRenderPlan { + val background: Color + val foreground: Color + val navigationTarget: ReaderContentNavigationTarget + val highlights: List + + data class WebDocument( + val html: String, + val appearanceScript: String, + override val background: Color, + override val foreground: Color, + override val navigationTarget: ReaderContentNavigationTarget, + override val highlights: List + ) : ReaderContentRenderPlan + + data class NativePaginatedPages( + val visiblePages: List, + val settings: ReaderSettings, + val searchQuery: String, + val searchOptions: ReaderSearchOptions, + val highlightPalette: ReaderHighlightPalette, + override val background: Color, + override val foreground: Color, + override val navigationTarget: ReaderContentNavigationTarget, + override val highlights: List + ) : ReaderContentRenderPlan +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderMinimalSlider.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderMinimalSlider.kt new file mode 100644 index 0000000..931a80e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderMinimalSlider.kt @@ -0,0 +1,119 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp + +@Composable +fun ReaderMinimalSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onValueChangeStarted: (() -> Unit)? = null, + onValueChangeFinished: (() -> Unit)? = null, + activeColor: Color? = null, + inactiveColor: Color? = null, + thumbColor: Color? = null +) { + var widthPx by remember { mutableFloatStateOf(0f) } + val rangeStart = valueRange.start + val rangeEnd = valueRange.endInclusive + + fun valueForOffset(offsetX: Float): Float { + if (widthPx <= 0f || rangeEnd <= rangeStart) return value.coerceIn(rangeStart, rangeEnd) + val fraction = (offsetX / widthPx).coerceIn(0f, 1f) + return rangeStart + (rangeEnd - rangeStart) * fraction + } + + val inputModifier = if (enabled) { + Modifier.pointerInput(rangeStart, rangeEnd, widthPx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + onValueChangeStarted?.invoke() + onValueChange(valueForOffset(down.position.x)) + down.consume() + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + onValueChange(valueForOffset(change.position.x)) + change.consume() + } + + onValueChangeFinished?.invoke() + } + } + } else { + Modifier + } + + val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary + val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant + val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary + val disabledAlpha = if (enabled) 1f else 0.38f + + Box( + modifier = modifier + .height(24.dp) + .onSizeChanged { widthPx = it.width.toFloat() } + .then(inputModifier) + ) { + Canvas(Modifier.fillMaxSize()) { + val range = rangeEnd - rangeStart + val fraction = if (range > 0f) { + ((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f) + } else { + 0f + } + val trackHeight = 4.dp.toPx() + val thumbRadius = 7.dp.toPx() + val centerY = size.height / 2f + val cornerRadius = CornerRadius(trackHeight / 2f, trackHeight / 2f) + val activeWidth = size.width * fraction + + drawRoundRect( + color = effectiveInactiveColor.copy(alpha = effectiveInactiveColor.alpha * disabledAlpha), + topLeft = Offset(0f, centerY - trackHeight / 2f), + size = Size(size.width, trackHeight), + cornerRadius = cornerRadius + ) + drawRoundRect( + color = effectiveActiveColor.copy(alpha = effectiveActiveColor.alpha * disabledAlpha), + topLeft = Offset(0f, centerY - trackHeight / 2f), + size = Size(activeWidth, trackHeight), + cornerRadius = cornerRadius + ) + + val thumbCenterX = if (size.width <= thumbRadius * 2f) { + size.width / 2f + } else { + activeWidth.coerceIn(thumbRadius, size.width - thumbRadius) + } + drawCircle( + color = effectiveThumbColor.copy(alpha = effectiveThumbColor.alpha * disabledAlpha), + radius = thumbRadius, + center = Offset(thumbCenterX, centerY) + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt index 0670b2d..df4657d 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt @@ -18,7 +18,8 @@ enum class ReaderWorkspaceLeftSection(val title: String) { CONTENTS("Contents"), SEARCH("Search"), BOOKMARKS("Bookmarks"), - NOTES("Notes") + NOTES("Annotations"), + PAGES("Pages") } enum class ReaderWorkspaceInspectorSection(val title: String) { @@ -32,6 +33,7 @@ enum class ReaderWorkspaceTopAction { CONTENTS, SEARCH, BOOKMARK, + FULL_SCREEN, APPEARANCE, READ_ALOUD, AI, @@ -51,6 +53,11 @@ data class ReaderWorkspaceChromeModel( val forceVisibleReasons: Set = emptySet() ) +data class ReaderWorkspacePanelDefaults( + val leftOpen: Boolean = false, + val inspectorOpen: Boolean = false +) + data class ReaderWorkspaceModel( val kind: ReaderWorkspaceKind, val leftSections: List, @@ -58,6 +65,7 @@ data class ReaderWorkspaceModel( val topActions: List, val bottomActions: List, val defaultPdfInteractionMode: PdfInkTool? = null, + val panelDefaults: ReaderWorkspacePanelDefaults = ReaderWorkspacePanelDefaults(), val chrome: ReaderWorkspaceChromeModel ) @@ -65,38 +73,38 @@ fun epubReaderWorkspaceModel( session: ReaderSessionState, toolbarPreferences: ReaderToolbarPreferences, extrasState: ReaderExtrasState, - aiAvailable: Boolean + aiAvailable: Boolean, + cloudTtsAvailable: Boolean = true, + externalLookupAvailable: Boolean = true ): ReaderWorkspaceModel { val preferences = toolbarPreferences.sanitized() - val leftSections = buildList { - if (preferences.isVisible(ReaderTool.TOC)) add(ReaderWorkspaceLeftSection.CONTENTS) - if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceLeftSection.SEARCH) - if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.BOOKMARKS) - if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.NOTES) - } + val leftSections = listOf( + ReaderWorkspaceLeftSection.CONTENTS, + ReaderWorkspaceLeftSection.NOTES, + ReaderWorkspaceLeftSection.BOOKMARKS + ) val inspectorSections = buildList { if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) { add(ReaderWorkspaceInspectorSection.APPEARANCE) } - if (preferences.isVisible(ReaderTool.READING_MODE) || preferences.isVisible(ReaderTool.VISUAL_OPTIONS)) { + if (preferences.isVisible(ReaderTool.READING_MODE)) { add(ReaderWorkspaceInspectorSection.TOOLS) } if ( - preferences.isVisible(ReaderTool.DICTIONARY) || - preferences.isVisible(ReaderTool.AI_FEATURES) || - preferences.isVisible(ReaderTool.TTS_CONTROLS) || + (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) || + (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) || preferences.isVisible(ReaderTool.AUTO_SCROLL) ) { add(ReaderWorkspaceInspectorSection.AI_TTS) } - add(ReaderWorkspaceInspectorSection.TOOLBAR) }.distinct() val topActions = buildList { if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS) if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH) if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK) + add(ReaderWorkspaceTopAction.FULL_SCREEN) if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE) - if (preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD) + if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD) if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI) if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL) if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS) @@ -113,7 +121,7 @@ fun epubReaderWorkspaceModel( topActions = topActions, bottomActions = bottomActions, chrome = readerWorkspaceChromeModel( - preferAutoHide = true, + preferAutoHide = false, searchActive = session.isSearchActive, leftPanelOpen = false, inspectorOpen = false, @@ -130,14 +138,18 @@ fun epubReaderWorkspaceModel( fun readerWorkspaceQuickActionTools( toolbarPreferences: ReaderToolbarPreferences, bottom: Boolean, - aiAvailable: Boolean + aiAvailable: Boolean, + cloudTtsAvailable: Boolean = true, + externalLookupAvailable: Boolean = true ): List { val preferences = toolbarPreferences.sanitized() return preferences.orderedVisibleTools() .filter { tool -> tool.supportsDesktopQuickAction && preferences.isBottom(tool) == bottom && - (tool != ReaderTool.AI_FEATURES || aiAvailable) + (tool != ReaderTool.AI_FEATURES || aiAvailable) && + (tool != ReaderTool.TTS_CONTROLS || cloudTtsAvailable) && + (tool != ReaderTool.DICTIONARY || externalLookupAvailable) } } @@ -154,13 +166,15 @@ fun pdfReaderWorkspaceModel( loading: Boolean, errorMessage: String?, extrasState: ReaderExtrasState, - aiAvailable: Boolean + aiAvailable: Boolean, + cloudTtsAvailable: Boolean = true, + externalLookupAvailable: Boolean = true ): ReaderWorkspaceModel { val leftSections = buildList { add(ReaderWorkspaceLeftSection.CONTENTS) - add(ReaderWorkspaceLeftSection.SEARCH) - if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS) - if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES) + add(ReaderWorkspaceLeftSection.NOTES) + add(ReaderWorkspaceLeftSection.BOOKMARKS) + add(ReaderWorkspaceLeftSection.PAGES) }.distinct() val inspectorSections = listOf( ReaderWorkspaceInspectorSection.APPEARANCE, @@ -172,8 +186,9 @@ fun pdfReaderWorkspaceModel( add(ReaderWorkspaceTopAction.CONTENTS) add(ReaderWorkspaceTopAction.SEARCH) add(ReaderWorkspaceTopAction.BOOKMARK) + add(ReaderWorkspaceTopAction.FULL_SCREEN) add(ReaderWorkspaceTopAction.APPEARANCE) - add(ReaderWorkspaceTopAction.READ_ALOUD) + if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD) if (aiAvailable) add(ReaderWorkspaceTopAction.AI) add(ReaderWorkspaceTopAction.AUTO_SCROLL) add(ReaderWorkspaceTopAction.TOOLS) @@ -190,11 +205,11 @@ fun pdfReaderWorkspaceModel( ), defaultPdfInteractionMode = null, chrome = readerWorkspaceChromeModel( - preferAutoHide = true, + preferAutoHide = false, searchActive = searchActive || state.searchQuery.isNotBlank(), leftPanelOpen = false, inspectorOpen = false, - annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN, + annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.NONE, richTextEditing = richTextEditing, loading = loading, errorMessage = errorMessage, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt index b992903..7ff0d25 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt @@ -1,44 +1,59 @@ package com.aryan.reader.shared.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BannerMessage +import com.aryan.reader.shared.reader.logSharedReaderDiagnostic import kotlinx.coroutines.delay +import kotlin.math.roundToInt @Composable fun ReaderWorkspaceShell( @@ -47,22 +62,46 @@ fun ReaderWorkspaceShell( subtitle: String, progressLabel: String, modifier: Modifier = Modifier, - topActions: @Composable RowScope.() -> Unit = {}, - leftSidebar: @Composable () -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + isFullscreen: Boolean = false, + onFullscreenChange: ((Boolean) -> Unit)? = null, + fullscreenExitMessage: String = "Esc to exit", + isBookmarked: Boolean = false, + onToggleBookmark: (() -> Unit)? = null, + onSearchAction: (() -> Unit)? = null, + topSearchBar: (@Composable () -> Unit)? = null, + leftSidebar: @Composable (closePanel: () -> Unit) -> Unit, rightInspector: @Composable () -> Unit, bottomBar: @Composable () -> Unit, + fullscreenBottomBar: (@Composable () -> Unit)? = null, content: @Composable BoxScope.() -> Unit ) { - var leftPanelOpen by remember(model.kind) { mutableStateOf(true) } - var rightPanelOpen by remember(model.kind) { mutableStateOf(true) } - var chromeVisible by remember(model.kind) { mutableStateOf(true) } - val forceChrome = model.chrome.forceVisible || leftPanelOpen || rightPanelOpen + var leftPanelOpen by remember(model.kind, model.panelDefaults.leftOpen) { + mutableStateOf(model.panelDefaults.leftOpen) + } + var rightPanelOpen by remember(model.kind, model.panelDefaults.inspectorOpen) { + mutableStateOf(model.panelDefaults.inspectorOpen) + } + var modalAnchorBounds by remember { mutableStateOf(null) } + var fullscreenBannerVisible by remember { mutableStateOf(false) } - LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) { - chromeVisible = true - if (model.chrome.preferAutoHide && !forceChrome) { - delay(3_200) - chromeVisible = false + LaunchedEffect(isFullscreen) { + if (isFullscreen) { + fullscreenBannerVisible = true + delay(2_600) + fullscreenBannerVisible = false + } else { + fullscreenBannerVisible = false + } + } + + LaunchedEffect(model.kind, model.chrome.forceVisibleReasons) { + val reasons = model.chrome.forceVisibleReasons + if (reasons.any { it == "search" }) { + leftPanelOpen = false + rightPanelOpen = false + } else if (reasons.any { it == "rich-text" } && model.inspectorSections.isNotEmpty()) { + rightPanelOpen = true } } @@ -70,82 +109,207 @@ fun ReaderWorkspaceShell( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - ) { - val wide = maxWidth >= 1120.dp - val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide + ) shellConstraints@ { + val wide = this@shellConstraints.maxWidth >= 1120.dp LaunchedEffect(wide, leftPanelOpen, rightPanelOpen) { if (!wide && leftPanelOpen && rightPanelOpen) { rightPanelOpen = false } } - Column( - modifier = Modifier.fillMaxSize().padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (showChrome) { - ReaderWorkspaceTopChrome( - title = title, - subtitle = subtitle, - progressLabel = progressLabel, - wide = wide, - leftPanelOpen = leftPanelOpen, - rightPanelOpen = rightPanelOpen, - onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen }, - onToggleRightPanel = { rightPanelOpen = !rightPanelOpen }, - topActions = topActions - ) - } - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (wide && leftPanelOpen && model.leftSections.isNotEmpty()) { - leftSidebar() - } + CompositionLocalProvider(LocalSharedReaderModalAnchorBounds provides modalAnchorBounds) { + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = if (isFullscreen) 0.dp else 8.dp, + top = if (isFullscreen) 0.dp else 8.dp, + end = if (isFullscreen) 0.dp else 8.dp + ) + .onGloballyPositioned { coordinates -> + logReaderGapLayout( + layer = "shell_column", + bounds = coordinates.boundsInWindow(), + details = if (isFullscreen) { + "fullscreen=true padding=0 verticalGap=0" + } else { + "fullscreen=false padding=start8 top8 end8 bottom0 verticalGap=6" + } + ) + }, + verticalArrangement = Arrangement.spacedBy(if (isFullscreen) 0.dp else 6.dp) + ) { + if (!isFullscreen || topSearchBar != null) { Box( modifier = Modifier - .weight(1f) - .fillMaxHeight() + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + logReaderGapLayout("top_chrome_slot", coordinates.boundsInWindow()) + } + ) { + if (topSearchBar != null) { + topSearchBar() + } else { + ReaderWorkspaceTopChrome( + title = title, + subtitle = subtitle, + progressLabel = progressLabel, + topActions = model.topActions, + hasLeftPanel = model.leftSections.isNotEmpty(), + hasRightPanel = model.inspectorSections.isNotEmpty(), + leftPanelOpen = leftPanelOpen, + rightPanelOpen = rightPanelOpen, + isBookmarked = isBookmarked, + onReturnToLibrary = onReturnToLibrary, + onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen }, + onToggleRightPanel = { rightPanelOpen = !rightPanelOpen }, + onToggleBookmark = onToggleBookmark, + onSearchAction = onSearchAction, + onEnterFullscreen = onFullscreenChange?.let { change -> { change(true) } } + ) + } + } + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + logReaderGapLayout("content_slot", coordinates.boundsInWindow()) + } + ) { + val showLeftPanel = !isFullscreen && leftPanelOpen && model.leftSections.isNotEmpty() + val showRightPanel = !isFullscreen && rightPanelOpen && model.inspectorSections.isNotEmpty() + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + val nextBounds = SharedReaderModalAnchorBounds( + leftPx = bounds.left, + topPx = bounds.top, + widthPx = bounds.width, + heightPx = bounds.height + ) + if (modalAnchorBounds != nextBounds) { + modalAnchorBounds = nextBounds + } + } ) { content() } - if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) { - rightInspector() - } + ReaderWorkspacePanelOverlays( + showLeftPanel = showLeftPanel, + showRightPanel = showRightPanel, + wide = wide, + onCloseLeftPanel = { leftPanelOpen = false }, + onCloseRightPanel = { rightPanelOpen = false }, + leftSidebar = leftSidebar, + rightInspector = rightInspector + ) } - if (!wide && leftPanelOpen && model.leftSections.isNotEmpty()) { - ReaderWorkspaceOverlayPanel( - title = "Reader", - onClose = { leftPanelOpen = false }, - modifier = Modifier.align(Alignment.CenterStart).width(320.dp) - ) { - leftSidebar() - } - } - if (!wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) { - ReaderWorkspaceOverlayPanel( - title = "Tools", - onClose = { rightPanelOpen = false }, - modifier = Modifier.align(Alignment.CenterEnd).width(360.dp) - ) { - rightInspector() + Box( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + logReaderGapLayout("bottom_bar_slot", coordinates.boundsInWindow()) + } + ) { + key(isFullscreen) { + val immersiveBottomBar = fullscreenBottomBar + if (isFullscreen && immersiveBottomBar != null) { + immersiveBottomBar() + } else { + bottomBar() + } } } } + } - if (showChrome) { - bottomBar() - } else { - Box( - Modifier - .fillMaxWidth() - .height(20.dp) - .clickable { chromeVisible = true } - ) + ReaderWorkspaceTopBanner( + bannerMessage = if (fullscreenBannerVisible) BannerMessage(fullscreenExitMessage) else null, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +} + +@Composable +private fun ReaderWorkspacePanelOverlays( + showLeftPanel: Boolean, + showRightPanel: Boolean, + wide: Boolean, + onCloseLeftPanel: () -> Unit, + onCloseRightPanel: () -> Unit, + leftSidebar: @Composable (closePanel: () -> Unit) -> Unit, + rightInspector: @Composable () -> Unit +) { + if (!showLeftPanel && !showRightPanel) return + + SharedReaderModalLayer( + level = SharedReaderModalLevel.Panel, + onDismiss = { + if (showLeftPanel) onCloseLeftPanel() + if (showRightPanel) onCloseRightPanel() + } + ) { + BoxWithConstraints(Modifier.fillMaxSize()) panelConstraints@ { + val availableWidth = this@panelConstraints.maxWidth + val leftPanelWidth = if (wide) 340.dp else minOf(320.dp, availableWidth * 0.92f) + val rightPanelWidth = if (wide) 380.dp else minOf(360.dp, availableWidth * 0.92f) + if (showLeftPanel) { + ReaderWorkspaceOverlayPanel( + title = "Reader", + onClose = onCloseLeftPanel, + modifier = Modifier + .align(Alignment.CenterStart) + .width(leftPanelWidth) + ) { + leftSidebar(onCloseLeftPanel) + } + } + if (showRightPanel) { + ReaderWorkspaceOverlayPanel( + title = "Tools", + onClose = onCloseRightPanel, + modifier = Modifier + .align(Alignment.CenterEnd) + .width(rightPanelWidth) + ) { + rightInspector() + } + } + } + } +} + +private const val ReaderGapLogTag = "EpistemeReaderGap" + +private fun logReaderGapLayout( + layer: String, + bounds: Rect, + details: String = "" +) { + logSharedReaderDiagnostic(ReaderGapLogTag) { + buildString { + append("compose_shell layer=") + append(layer) + append(" x=") + append(bounds.left.roundToInt()) + append(" y=") + append(bounds.top.roundToInt()) + append(" w=") + append(bounds.width.roundToInt()) + append(" h=") + append(bounds.height.roundToInt()) + append(" bottom=") + append(bounds.bottom.roundToInt()) + if (details.isNotBlank()) { + append(' ') + append(details) } } } @@ -156,43 +320,109 @@ private fun ReaderWorkspaceTopChrome( title: String, subtitle: String, progressLabel: String, - wide: Boolean, + topActions: List, + hasLeftPanel: Boolean, + hasRightPanel: Boolean, leftPanelOpen: Boolean, rightPanelOpen: Boolean, + isBookmarked: Boolean, + onReturnToLibrary: (() -> Unit)?, onToggleLeftPanel: () -> Unit, onToggleRightPanel: () -> Unit, - topActions: @Composable RowScope.() -> Unit + onToggleBookmark: (() -> Unit)?, + onSearchAction: (() -> Unit)?, + onEnterFullscreen: (() -> Unit)? ) { Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp + tonalElevation = 1.dp ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - IconButton(onClick = onToggleLeftPanel) { - Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation") + onReturnToLibrary?.let { returnToLibrary -> + IconButton(onClick = returnToLibrary, modifier = Modifier.size(36.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library") + } + } + if (hasLeftPanel) { + IconButton(onClick = onToggleLeftPanel, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation") + } } Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } Text(progressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { - topActions() - } - IconButton(onClick = onToggleRightPanel) { - Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools") - } - if (!wide) { - TextButton(onClick = onToggleRightPanel, contentPadding = PaddingValues(horizontal = 8.dp)) { - Text("Tools") + if (ReaderWorkspaceTopAction.SEARCH in topActions && onSearchAction != null) { + IconButton(onClick = onSearchAction, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Search, contentDescription = "Search in reader") } } + if (ReaderWorkspaceTopAction.BOOKMARK in topActions && onToggleBookmark != null) { + IconButton(onClick = onToggleBookmark, modifier = Modifier.size(36.dp)) { + Icon( + if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = if (isBookmarked) "Remove bookmark" else "Add bookmark" + ) + } + } + if (ReaderWorkspaceTopAction.FULL_SCREEN in topActions && onEnterFullscreen != null) { + IconButton(onClick = onEnterFullscreen, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Fullscreen, contentDescription = "Enter full screen") + } + } + if (hasRightPanel) { + IconButton(onClick = onToggleRightPanel, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools") + } + } + } + } +} + +@Composable +private fun ReaderWorkspaceTopBanner( + bannerMessage: BannerMessage?, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = bannerMessage != null, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = if (bannerMessage?.isError == true) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + shape = MaterialTheme.shapes.medium, + shadowElevation = 8.dp + ) { + Text( + text = bannerMessage?.message.orEmpty(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + color = if (bannerMessage?.isError == true) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt index 08c893d..0d3913c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Feedback import androidx.compose.material.icons.filled.Folder @@ -32,6 +33,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.ImportExport import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.TextFields @@ -66,6 +68,7 @@ import androidx.compose.ui.unit.dp import com.aryan.reader.shared.AppContrastOption import com.aryan.reader.shared.AppThemeMode import com.aryan.reader.shared.CustomAppTheme +import com.aryan.reader.shared.SharedFeaturePolicy enum class SharedAppTab { HOME, @@ -73,6 +76,7 @@ enum class SharedAppTab { SHELVES, CATALOGS, READER, + SETTINGS, CUSTOM_FONTS, SUPPORT, FEEDBACK, @@ -89,11 +93,13 @@ fun SharedAppShell( appTextDimFactorDark: Float = 1.0f, appSeedColor: Color? = null, customAppThemes: List = emptyList(), - isTabsEnabled: Boolean = false, + isTabsEnabled: Boolean = true, + featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, onTabSelected: (SharedAppTab) -> Unit, onImportFiles: () -> Unit, onImportFolder: () -> Unit = {}, onSyncRequested: () -> Unit, + onFolderMetadataSyncRequested: (() -> Unit)? = null, onAppThemeModeChange: (AppThemeMode) -> Unit = {}, onAppContrastOptionChange: (AppContrastOption) -> Unit = {}, onAppTextDimFactorLightChange: (Float) -> Unit = {}, @@ -105,10 +111,12 @@ fun SharedAppShell( onAiSettingsRequested: (() -> Unit)? = null, content: @Composable (SharedAppTab) -> Unit ) { - val shellModel = remember(selectedTab, onAiSettingsRequested != null) { + val aiSettingsAvailable = onAiSettingsRequested != null && featurePolicy.aiAndCloud + val shellModel = remember(selectedTab, aiSettingsAvailable, featurePolicy) { sharedAppShellModel( selectedTab = selectedTab, - aiSettingsAvailable = onAiSettingsRequested != null + aiSettingsAvailable = aiSettingsAvailable, + featurePolicy = featurePolicy ) } var showToolsPanel by remember { mutableStateOf(false) } @@ -126,20 +134,22 @@ fun SharedAppShell( ) { val useSidebar = maxWidth >= 900.dp Row(Modifier.fillMaxSize()) { - if (useSidebar) { - SharedAppSidebar( - selectedTab = shellModel.selectedPrimaryTab, - primaryTabs = shellModel.primaryTabs, - onTabSelected = onTabSelected, - onToolsClick = { showToolsPanel = true } - ) - } else { - SharedAppCompactRail( - selectedTab = shellModel.selectedPrimaryTab, - primaryTabs = shellModel.primaryTabs, - onTabSelected = onTabSelected, - onToolsClick = { showToolsPanel = true } - ) + if (shellModel.showPrimaryNavigation) { + if (useSidebar) { + SharedAppSidebar( + selectedTab = shellModel.selectedPrimaryTab, + primaryTabs = shellModel.primaryTabs, + onTabSelected = onTabSelected, + onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) } + ) + } else { + SharedAppCompactRail( + selectedTab = shellModel.selectedPrimaryTab, + primaryTabs = shellModel.primaryTabs, + onTabSelected = onTabSelected, + onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) } + ) + } } Box( @@ -165,7 +175,7 @@ fun SharedAppShell( .fillMaxHeight() .widthIn(max = 390.dp), isTabsEnabled = isTabsEnabled, - aiSettingsAvailable = onAiSettingsRequested != null, + toolActions = shellModel.toolActions, onClose = { showToolsPanel = false }, onImportFiles = { showToolsPanel = false @@ -179,6 +189,12 @@ fun SharedAppShell( showToolsPanel = false onSyncRequested() }, + onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata -> + { + showToolsPanel = false + syncMetadata() + } + }, onAppThemeRequested = { showToolsPanel = false showAppThemeSettings = true @@ -251,7 +267,7 @@ private fun SharedAppSidebar( Spacer(Modifier.weight(1f)) HorizontalDivider() SharedSidebarButton( - label = "Tools", + label = "Settings", icon = Icons.Default.Settings, onClick = onToolsClick ) @@ -277,7 +293,7 @@ private fun SharedAppCompactRail( } Spacer(Modifier.weight(1f)) IconButton(onClick = onToolsClick) { - Icon(Icons.Default.Settings, contentDescription = "Tools") + Icon(Icons.Default.Settings, contentDescription = "Settings") } } } @@ -344,16 +360,26 @@ private fun SharedSidebarButton( private fun SharedToolsPanel( modifier: Modifier, isTabsEnabled: Boolean, - aiSettingsAvailable: Boolean, + toolActions: List, onClose: () -> Unit, onImportFiles: () -> Unit, onImportFolder: () -> Unit, onSyncRequested: () -> Unit, + onFolderMetadataSyncRequested: (() -> Unit)?, onAppThemeRequested: () -> Unit, onAiSettingsRequested: () -> Unit, onOpenTab: (SharedAppTab) -> Unit, onTabsEnabledChange: (Boolean) -> Unit ) { + val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions || + SharedAppToolAction.IMPORT_FOLDER in toolActions || + SharedAppToolAction.SYNC in toolActions + val hasSettingsActions = SharedAppToolAction.AI_SETTINGS in toolActions || + SharedAppToolAction.CUSTOM_FONTS in toolActions + val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions || + SharedAppToolAction.SUPPORT in toolActions || + SharedAppToolAction.ABOUT in toolActions + Surface( modifier = modifier, color = MaterialTheme.colorScheme.surface, @@ -377,60 +403,91 @@ private fun SharedToolsPanel( } } - SharedToolsSection("Library") { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) { - Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Files") + if (hasLibraryActions) { + SharedToolsSection("Library") { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + if (SharedAppToolAction.IMPORT_FILES in toolActions) { + Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import files") + } + } + if (SharedAppToolAction.IMPORT_FOLDER in toolActions) { + OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Add folder") + } + } } - OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Folder") + if (SharedAppToolAction.SYNC in toolActions) { + if (onFolderMetadataSyncRequested == null) { + FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Sync folders") + } + } else { + SharedToolRow(Icons.Default.Sync, "Sync metadata", onFolderMetadataSyncRequested) + SharedToolRow(Icons.Default.Search, "Full scan") { + onSyncRequested() + } + } } } - FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) { - Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Sync folders") - } } SharedToolsSection("Appearance") { - SharedToolRow( - icon = Icons.Default.Palette, - title = "App theme", - onClick = onAppThemeRequested - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Switch( - checked = isTabsEnabled, - onCheckedChange = onTabsEnabledChange + if (SharedAppToolAction.APP_THEME in toolActions) { + SharedToolRow( + icon = Icons.Default.Palette, + title = "App theme", + onClick = onAppThemeRequested ) } - } - - SharedToolsSection("Settings") { - if (aiSettingsAvailable) { - SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested) + if (SharedAppToolAction.TABS_TOGGLE in toolActions) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = isTabsEnabled, + onCheckedChange = onTabsEnabledChange + ) + } } - SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) } } - SharedToolsSection("Project") { - SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) } - SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) } - SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) } + if (hasSettingsActions) { + SharedToolsSection("Settings") { + if (SharedAppToolAction.AI_SETTINGS in toolActions) { + SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested) + } + if (SharedAppToolAction.CUSTOM_FONTS in toolActions) { + SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) } + } + } + } + + if (hasProjectActions) { + SharedToolsSection("Project") { + if (SharedAppToolAction.HELP_FEEDBACK in toolActions) { + SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) } + } + if (SharedAppToolAction.SUPPORT in toolActions) { + SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) } + } + if (SharedAppToolAction.ABOUT in toolActions) { + SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) } + } + } } Spacer(Modifier.height(12.dp)) @@ -479,6 +536,7 @@ private val SharedAppTab.label: String SharedAppTab.SHELVES -> "Shelves" SharedAppTab.CATALOGS -> "OPDS" SharedAppTab.READER -> "Reader" + SharedAppTab.SETTINGS -> "Settings" SharedAppTab.CUSTOM_FONTS -> "Custom fonts" SharedAppTab.SUPPORT -> "Support" SharedAppTab.FEEDBACK -> "Feedback" @@ -492,6 +550,7 @@ private val SharedAppTab.icon: ImageVector SharedAppTab.SHELVES -> Icons.Default.Folder SharedAppTab.CATALOGS -> Icons.Default.Cloud SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook + SharedAppTab.SETTINGS -> Icons.Default.Settings SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields SharedAppTab.SUPPORT -> Icons.Default.Favorite SharedAppTab.FEEDBACK -> Icons.Default.Feedback diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt index bcf9b28..acf6166 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -40,8 +41,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.Typography @@ -65,7 +66,9 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -75,7 +78,13 @@ import com.aryan.reader.shared.AppThemeMode import com.aryan.reader.shared.CustomAppTheme import com.materialkolor.PaletteStyle import com.materialkolor.dynamicColorScheme +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.min import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt import kotlin.random.Random private val SharedLightColorScheme = lightColorScheme( @@ -524,7 +533,7 @@ private fun SharedCreateAppThemeDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp) ) { - OutlinedTextField( + SharedStableOutlinedTextField( value = name, onValueChange = { name = it }, label = { Text("Theme name") }, @@ -616,7 +625,248 @@ private fun SharedCreateAppThemeDialog( } @Composable -private fun SharedSpectrumBox( +fun SharedHsvColorPickerDialog( + initialColor: Color, + title: String, + onDismiss: () -> Unit, + onSave: (Color) -> Unit, + modifier: Modifier = Modifier, + preview: @Composable (Color) -> Unit = {} +) { + var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) } + val color = hsv.toComposeColor() + + fun updateFromColor(nextColor: Color) { + hsv = nextColor.toSharedHsvColor() + } + + SharedReaderModalLayer(onDismiss = onDismiss) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val dialogHorizontalPadding = 24.dp + val dialogAvailableWidth = (maxWidth - dialogHorizontalPadding - dialogHorizontalPadding).coerceAtLeast(0.dp) + Surface( + modifier = Modifier + .padding(dialogHorizontalPadding) + .width(sharedReaderPopupWidth(dialogAvailableWidth)) + .heightIn(max = 600.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f)) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + Column( + modifier = modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + preview(color) + + SharedHsvWheel( + hue = hsv.hue, + saturation = hsv.saturation, + currentColor = color, + onHueSatChanged = { hue, saturation -> + hsv = hsv.copy(hue = hue, saturation = saturation) + }, + modifier = Modifier.size(240.dp) + ) + + SharedBrightnessSlider( + hue = hsv.hue, + saturation = hsv.saturation, + value = hsv.value, + onValueChanged = { hsv = hsv.copy(value = it) }, + modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SharedColorComparePill( + oldColor = initialColor, + newColor = color, + modifier = Modifier.width(64.dp).height(36.dp) + ) + + Column( + modifier = Modifier.weight(1.6f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1) + Spacer(Modifier.height(4.dp)) + SharedHexInput(color = color, onHexChanged = { updateFromColor(it) }) + } + + Row( + modifier = Modifier.weight(2.4f), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + SharedRgbInputColumn( + label = "R", + value = color.red, + onValueChange = { updateFromColor(color.copy(red = it)) }, + modifier = Modifier.weight(1f) + ) + SharedRgbInputColumn( + label = "G", + value = color.green, + onValueChange = { updateFromColor(color.copy(green = it)) }, + modifier = Modifier.weight(1f) + ) + SharedRgbInputColumn( + label = "B", + value = color.blue, + onValueChange = { updateFromColor(color.copy(blue = it)) }, + modifier = Modifier.weight(1f) + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + Button( + onClick = { onSave(color) }, + colors = ButtonDefaults.buttonColors( + containerColor = color, + contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White + ) + ) { + Text("Save", fontWeight = FontWeight.Bold) + } + } + } + } + } + } +} + +@Composable +fun SharedHsvWheel( + hue: Float, + saturation: Float, + currentColor: Color, + onHueSatChanged: (Float, Float) -> Unit, + modifier: Modifier = Modifier +) { + val touchPadding = 12.dp + + Box( + modifier = modifier.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val paddingPx = touchPadding.toPx() + + fun update(offset: Offset) { + val selection = sharedHsvWheelSelection( + offsetX = offset.x, + offsetY = offset.y, + width = size.width.toFloat(), + height = size.height.toFloat(), + paddingPx = paddingPx + ) + onHueSatChanged(selection.hue, selection.saturation) + } + + update(down.position) + drag(down.id) { change -> + change.consume() + update(change.position) + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val paddingPx = touchPadding.toPx() + val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f) + val center = Offset(size.width / 2f, size.height / 2f) + val topLeft = Offset(center.x - wheelRadius, center.y - wheelRadius) + val wheelSize = Size(wheelRadius * 2f, wheelRadius * 2f) + val segments = 180 + val sweep = 360f / segments + + repeat(segments) { index -> + val segmentHue = index * sweep + drawArc( + brush = Brush.radialGradient( + colors = listOf(Color.White, Color.hsv(segmentHue, 1f, 1f)), + center = center, + radius = wheelRadius + ), + startAngle = segmentHue, + sweepAngle = sweep + 0.8f, + useCenter = true, + topLeft = topLeft, + size = wheelSize + ) + } + + drawCircle( + color = Color.Black.copy(alpha = 0.16f), + radius = wheelRadius, + center = center, + style = Stroke(width = 1.dp.toPx()) + ) + } + + Canvas(modifier = Modifier.fillMaxSize()) { + val paddingPx = touchPadding.toPx() + val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f) + val center = Offset(size.width / 2f, size.height / 2f) + val angle = hue.normalizedHue().toDouble() * PI / 180.0 + val radius = saturation.coerceIn(0f, 1f) * wheelRadius + val pointer = Offset( + x = center.x + (cos(angle).toFloat() * radius), + y = center.y + (sin(angle).toFloat() * radius) + ) + val pointerRadius = 10.dp.toPx() + val strokeWidth = 2.dp.toPx() + + drawCircle( + color = Color.Black.copy(alpha = 0.25f), + radius = pointerRadius + 1.dp.toPx(), + center = Offset(pointer.x, pointer.y + 1.dp.toPx()) + ) + drawCircle( + color = currentColor.copy(alpha = 1f), + radius = pointerRadius, + center = pointer + ) + drawCircle( + color = Color.White, + radius = pointerRadius, + center = pointer, + style = Stroke(width = strokeWidth) + ) + } + } +} + +@Composable +fun SharedSpectrumBox( hue: Float, saturation: Float, currentColor: Color, @@ -702,7 +952,7 @@ private fun SharedSpectrumBox( } @Composable -private fun SharedBrightnessSlider( +fun SharedBrightnessSlider( hue: Float, saturation: Float, value: Float, @@ -747,7 +997,7 @@ private fun SharedBrightnessSlider( } @Composable -private fun SharedRgbInputColumn( +fun SharedRgbInputColumn( label: String, value: Float, onValueChange: (Float) -> Unit, @@ -774,13 +1024,17 @@ private fun SharedRgbInput( value: Int, onValueChange: (Float) -> Unit ) { - var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) } + var textFieldValue by remember(value) { + val text = value.coerceIn(0, 255).toString() + mutableStateOf(TextFieldValue(text, TextRange(text.length))) + } BasicTextField( - value = text, - onValueChange = { newText -> + value = textFieldValue, + onValueChange = { nextValue -> + val newText = nextValue.text if (newText.length <= 3 && newText.all { it.isDigit() }) { - text = newText + textFieldValue = nextValue newText.toIntOrNull()?.let { channel -> onValueChange(channel.coerceIn(0, 255) / 255f) } @@ -802,12 +1056,14 @@ private fun SharedRgbInput( } @Composable -private fun SharedHexInput( +fun SharedHexInput( color: Color, onHexChanged: (Color) -> Unit ) { val hexValue = color.toSharedHexString().removePrefix("#") - var text by remember(hexValue) { mutableStateOf(hexValue) } + var textFieldValue by remember(hexValue) { + mutableStateOf(TextFieldValue(hexValue, TextRange(hexValue.length))) + } Row( modifier = Modifier @@ -825,12 +1081,16 @@ private fun SharedHexInput( fontWeight = FontWeight.Bold ) BasicTextField( - value = text, - onValueChange = { newText -> + value = textFieldValue, + onValueChange = { nextValue -> + val newText = nextValue.text if (newText.length <= 6) { val uppercased = newText.uppercase() if (uppercased.all { it.isDigit() || it in 'A'..'F' }) { - text = uppercased + textFieldValue = nextValue.copy( + text = uppercased, + selection = TextRange(nextValue.selection.end.coerceIn(0, uppercased.length)) + ) if (uppercased.length == 6) { uppercased.toSharedHexColorOrNull()?.let(onHexChanged) } @@ -852,7 +1112,7 @@ private fun SharedHexInput( } @Composable -private fun SharedColorComparePill( +fun SharedColorComparePill( oldColor: Color, newColor: Color, modifier: Modifier = Modifier @@ -943,6 +1203,27 @@ internal data class SharedHsvColor( } } +internal fun sharedHsvWheelSelection( + offsetX: Float, + offsetY: Float, + width: Float, + height: Float, + paddingPx: Float = 0f +): SharedHsvColor { + val wheelRadius = ((min(width, height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f) + val centerX = width / 2f + val centerY = height / 2f + val dx = offsetX - centerX + val dy = offsetY - centerY + val hue = (atan2(dy.toDouble(), dx.toDouble()) * 180.0 / PI).toFloat().normalizedHue() + val saturation = (sqrt(((dx * dx) + (dy * dy)).toDouble()).toFloat() / wheelRadius).coerceIn(0f, 1f) + return SharedHsvColor( + hue = hue, + saturation = saturation, + value = 1f + ) +} + internal fun Color.toSharedHsvColor(): SharedHsvColor { val maximum = maxOf(red, green, blue) val minimum = minOf(red, green, blue) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt index 63e2ca1..f410436 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt @@ -1,23 +1,44 @@ package com.aryan.reader.shared.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Save import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -28,9 +49,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.Tag import com.aryan.reader.shared.cardTitle @@ -51,7 +80,7 @@ fun SharedTextInputDialog( onDismissRequest = onDismiss, title = { Text(title) }, text = { - OutlinedTextField( + SharedStableOutlinedTextField( value = value, onValueChange = { value = it }, label = { Text(label) }, @@ -121,7 +150,12 @@ fun SharedAddToShelfDialog( Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(10.dp)) - Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + shelf.name, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -145,98 +179,719 @@ fun SharedAddToShelfDialog( @Composable fun SharedBookInfoDialog( book: BookItem, + knownTags: List = emptyList(), + initiallyEditing: Boolean = false, + canEditEmbeddedMetadata: Boolean = book.type == FileType.EPUB, + canRenameDisplayName: Boolean = true, + canRestoreEmbeddedMetadata: Boolean = canEditEmbeddedMetadata, onDismiss: () -> Unit, - onEdit: () -> Unit + onSave: (BookItem) -> Unit, + onRestore: (BookItem) -> Unit ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(book.cardTitle()) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SharedInfoRow("File", book.displayName) - SharedInfoRow("Type", book.type.name) - SharedInfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" }) - SharedInfoRow("Path", book.path.orEmpty().ifBlank { "Not available" }) - SharedInfoRow("Size", formatFileSize(book.fileSize)) - SharedInfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%") - if (!book.seriesName.isNullOrBlank()) { - SharedInfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #")) - } - if (book.tags.isNotEmpty()) { - SharedInfoRow("Tags", book.tags.joinToString { it.name }) - } - } - }, - confirmButton = { - TextButton(onClick = onEdit) { - Text("Edit") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Close") - } - } - ) -} + val clipboard = LocalClipboardManager.current + var isEditing by remember(book.id, initiallyEditing) { mutableStateOf(initiallyEditing) } + var titleInput by remember(book.id, book.title) { mutableStateOf(book.title.orEmpty()) } + var authorInput by remember(book.id, book.author) { mutableStateOf(book.author.orEmpty()) } + var seriesInput by remember(book.id, book.seriesName) { mutableStateOf(book.seriesName.orEmpty()) } + var seriesIndexInput by remember(book.id, book.seriesIndex) { + mutableStateOf(book.seriesIndex?.formatMetadataNumber().orEmpty()) + } + var descriptionInput by remember(book.id, book.description) { mutableStateOf(book.description.orEmpty()) } + var displayNameInput by remember(book.id, book.displayName) { mutableStateOf(book.displayName) } + var tagInput by remember(book.id, book.tags) { mutableStateOf(book.tags.joinToString(", ") { it.name }) } + var showRestoreConfirmation by remember(book.id) { mutableStateOf(false) } -@Composable -fun SharedBookEditDialog( - book: BookItem, - knownTags: List, - onDismiss: () -> Unit, - onSave: (BookItem) -> Unit -) { - var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) } - var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) } - var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) } - var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) } - var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) } + val hasOriginalMetadata = book.hasOriginalMetadata() + val hasMetadataChanges = book.hasMetadataChanges() - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Edit book") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - if (knownTags.isNotEmpty()) { - Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + Dialog( + onDismissRequest = { + if (isEditing) { + isEditing = false + } else { + onDismiss() } }, - confirmButton = { - TextButton( - onClick = { - onSave( - book.copy( - title = title.trim().ifBlank { null }, - author = author.trim().ifBlank { null }, - seriesName = seriesName.trim().ifBlank { null }, - seriesIndex = seriesIndex.toDoubleOrNull(), - tags = parseTagList(tagText, knownTags) + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxSize() + ) { + Column(Modifier.fillMaxSize()) { + SharedBookInfoTopBar( + title = if (isEditing) { + if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app" + } else { + "Book information" + }, + subtitle = book.cardTitle(), + onClose = { + if (isEditing) { + isEditing = false + } else { + onDismiss() + } + } + ) + + HorizontalDivider() + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isEditing) { + if (canEditEmbeddedMetadata) { + SharedBookMetadataEditContent( + titleInput = titleInput, + onTitleChange = { titleInput = it }, + authorInput = authorInput, + onAuthorChange = { authorInput = it }, + seriesInput = seriesInput, + onSeriesChange = { seriesInput = it }, + seriesIndexInput = seriesIndexInput, + onSeriesIndexChange = { seriesIndexInput = it }, + descriptionInput = descriptionInput, + onDescriptionChange = { descriptionInput = it }, + tagInput = tagInput, + onTagChange = { tagInput = it }, + knownTags = knownTags + ) + } else if (canRenameDisplayName) { + SharedBookDisplayNameEditContent( + displayNameInput = displayNameInput, + onDisplayNameChange = { displayNameInput = it }, + tagInput = tagInput, + onTagChange = { tagInput = it }, + knownTags = knownTags + ) + } + } else { + SharedBookMetadataInfoContent( + book = book, + hasMetadataChanges = hasMetadataChanges, + onCopyPath = { + book.path?.takeIf { it.isNotBlank() }?.let { clipboard.setText(AnnotatedString(it)) } + } ) - ) + } } - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") + + HorizontalDivider() + + SharedBookInfoBottomBar( + isEditing = isEditing, + canEdit = canEditEmbeddedMetadata || canRenameDisplayName, + canRestore = canRestoreEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing), + editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename", + onCancel = { + if (isEditing) { + isEditing = false + } else { + onDismiss() + } + }, + onRestore = { showRestoreConfirmation = true }, + onSave = { + val updated = if (canEditEmbeddedMetadata) { + book.copy( + title = titleInput.toMetadataValue() + ?: book.displayName.substringBeforeLast('.', book.displayName), + author = authorInput.toMetadataValue(), + seriesName = seriesInput.toMetadataValue(), + seriesIndex = seriesIndexInput.toSeriesIndexOrNull(), + description = descriptionInput.toMetadataValue(), + originalTitle = book.originalTitle ?: book.title, + originalAuthor = book.originalAuthor ?: book.author, + originalSeriesName = book.originalSeriesName ?: book.seriesName, + originalSeriesIndex = book.originalSeriesIndex ?: book.seriesIndex, + originalDescription = book.originalDescription ?: book.description, + tags = parseTagList(tagInput, knownTags) + ) + } else { + book.copy( + displayName = displayNameInput.toMetadataValue() ?: book.displayName, + tags = parseTagList(tagInput, knownTags) + ) + } + onSave(updated) + onDismiss() + }, + onEdit = { isEditing = true } + ) } } - ) -} + } -@Composable -private fun SharedInfoRow(label: String, value: String) { - Column { - Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, style = MaterialTheme.typography.bodyMedium) + if (showRestoreConfirmation) { + AlertDialog( + onDismissRequest = { showRestoreConfirmation = false }, + icon = { Icon(Icons.Default.Restore, contentDescription = null) }, + title = { Text("Restore original metadata?") }, + text = { + Text( + "This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change." + ) + }, + confirmButton = { + Button( + onClick = { + showRestoreConfirmation = false + onRestore(book.restoredOriginalMetadata()) + onDismiss() + } + ) { + Text("Restore") + } + }, + dismissButton = { + TextButton(onClick = { showRestoreConfirmation = false }) { + Text("Cancel") + } + } + ) } } + +@Composable +private fun SharedBookInfoTopBar( + title: String, + subtitle: String, + onClose: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SharedBookMetadataInfoContent( + book: BookItem, + hasMetadataChanges: Boolean, + onCopyPath: () -> Unit +) { + SharedInfoCard { + Text( + book.cardTitle(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + book.author + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?.let { + Text( + it, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + val provenance = when { + book.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited" + book.type == FileType.EPUB -> "Metadata from EPUB file" + hasMetadataChanges -> "Display name changed in app" + else -> "Metadata from file" + } + Text( + provenance, + style = MaterialTheme.typography.labelMedium, + color = if (hasMetadataChanges) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + SharedInfoSection(title = "Metadata") { + SharedInfoRowDetailed("Title", book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3) + book.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { + SharedInfoRowDetailed("Author", it, maxLines = 2) + } + book.seriesLabel()?.let { + SharedInfoRowDetailed("Series", it, maxLines = 2) + } + SharedInfoRowDetailed("Format", book.type.name) + SharedInfoRowDetailed("Size", formatFileSize(book.fileSize)) + SharedInfoRowDetailed("Reading", book.readingProgressText(), maxLines = 2) + } + + SharedInfoSection(title = "File") { + SharedInfoRowDetailed("File name", book.displayName, maxLines = 2) + SharedInfoRowDetailed("Location", book.path.orEmpty().ifBlank { "Not available" }, maxLines = 4, onCopy = onCopyPath) + book.sourceFolder?.takeIf { it.isNotBlank() }?.let { + SharedInfoRowDetailed("Source folder", it, maxLines = 3) + } + } + + book.description?.takeIf { it.isNotBlank() }?.let { summary -> + SharedInfoSection(title = "Summary") { + SharedExpandableSummaryText(summary, collapsedMaxLines = 4) + } + } + + SharedInfoSection(title = "Tags") { + if (book.tags.isEmpty()) { + Text( + "No tags assigned", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + book.tags.forEach { tag -> + AssistChip(onClick = {}, label = { Text(tag.name) }) + } + } + } + } +} + +@Composable +private fun SharedBookMetadataEditContent( + titleInput: String, + onTitleChange: (String) -> Unit, + authorInput: String, + onAuthorChange: (String) -> Unit, + seriesInput: String, + onSeriesChange: (String) -> Unit, + seriesIndexInput: String, + onSeriesIndexChange: (String) -> Unit, + descriptionInput: String, + onDescriptionChange: (String) -> Unit, + tagInput: String, + onTagChange: (String) -> Unit, + knownTags: List +) { + SharedInfoSection(title = "Editable metadata") { + SharedStableOutlinedTextField( + value = titleInput, + onValueChange = onTitleChange, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + selectionKey = "title" + ) + SharedStableOutlinedTextField( + value = authorInput, + onValueChange = onAuthorChange, + label = { Text("Author") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + selectionKey = "author" + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SharedStableOutlinedTextField( + value = seriesInput, + onValueChange = onSeriesChange, + label = { Text("Series") }, + modifier = Modifier.weight(1f), + maxLines = 2, + selectionKey = "series" + ) + SharedStableOutlinedTextField( + value = seriesIndexInput, + onValueChange = onSeriesIndexChange, + label = { Text("#") }, + modifier = Modifier.width(96.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + selectionKey = "seriesIndex" + ) + } + SharedStableOutlinedTextField( + value = descriptionInput, + onValueChange = onDescriptionChange, + label = { Text("Summary") }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + minLines = 4, + maxLines = 10, + selectionKey = "description" + ) + } + + SharedInfoSection(title = "Library tags") { + SharedStableOutlinedTextField( + value = tagInput, + onValueChange = onTagChange, + label = { Text("Tags, comma separated") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + selectionKey = "tags" + ) + if (knownTags.isNotEmpty()) { + Text( + "Existing: ${knownTags.joinToString { it.name }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SharedBookDisplayNameEditContent( + displayNameInput: String, + onDisplayNameChange: (String) -> Unit, + tagInput: String, + onTagChange: (String) -> Unit, + knownTags: List +) { + SharedInfoSection(title = "Display name") { + SharedStableOutlinedTextField( + value = displayNameInput, + onValueChange = onDisplayNameChange, + label = { Text("Name shown in Reader") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + selectionKey = "displayName" + ) + } + + SharedInfoSection(title = "Library tags") { + SharedStableOutlinedTextField( + value = tagInput, + onValueChange = onTagChange, + label = { Text("Tags, comma separated") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + selectionKey = "renameTags" + ) + if (knownTags.isNotEmpty()) { + Text( + "Existing: ${knownTags.joinToString { it.name }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SharedBookInfoBottomBar( + isEditing: Boolean, + canEdit: Boolean, + canRestore: Boolean, + editLabel: String, + onCancel: () -> Unit, + onRestore: () -> Unit, + onSave: () -> Unit, + onEdit: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (canRestore) { + OutlinedButton( + onClick = onRestore, + modifier = Modifier.padding(end = 8.dp) + ) { + Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Restore") + } + } + TextButton(onClick = onCancel) { + Text(if (isEditing) "Cancel" else "Close") + } + Spacer(Modifier.width(8.dp)) + if (isEditing) { + Button(onClick = onSave) { + Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Save") + } + } else if (canEdit) { + Button(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(editLabel) + } + } + } +} + +@Composable +private fun SharedInfoSection( + title: String, + content: @Composable () -> Unit +) { + SharedInfoCard { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + content() + } +} + +@Composable +private fun SharedInfoCard(content: @Composable ColumnScope.() -> Unit) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content + ) + } +} + +@Composable +private fun SharedInfoRowDetailed( + label: String, + value: String, + maxLines: Int = 1, + onCopy: (() -> Unit)? = null +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Text( + text = label, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .width(112.dp) + .padding(top = 2.dp) + ) + Column(modifier = Modifier.weight(1f)) { + SharedExpandableValueText(value, collapsedMaxLines = maxLines) + } + if (onCopy != null && value != "Not available") { + TextButton( + onClick = onCopy, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp), + modifier = Modifier.height(30.dp) + ) { + Text("Copy") + } + } + } +} + +@Composable +private fun SharedExpandableValueText( + value: String, + collapsedMaxLines: Int +) { + var expanded by remember(value) { mutableStateOf(false) } + val canExpand = collapsedMaxLines < Int.MAX_VALUE && (value.length > 120 || value.contains('\n')) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines, + overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + if (canExpand) { + SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + } +} + +@Composable +private fun SharedExpandableSummaryText( + value: String, + collapsedMaxLines: Int +) { + var expanded by remember(value) { mutableStateOf(false) } + val renderableSummary = remember(value) { + if (value.looksLikeHtml()) value.htmlToMarkdownSummary() else value + } + val canExpand = value.length > 220 || value.count { it == '\n' } >= collapsedMaxLines || value.looksLikeHtml() + val contentModifier = if (expanded) { + Modifier.fillMaxWidth() + } else { + Modifier + .fillMaxWidth() + .heightIn(max = (collapsedMaxLines * 26).dp) + .clipToBounds() + } + + Box(modifier = contentModifier) { + SharedMarkdownText( + markdown = renderableSummary, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium + ) + } + + if (canExpand) { + SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + } +} + +@Composable +private fun SharedMoreButton( + expanded: Boolean, + onClick: () -> Unit +) { + TextButton( + onClick = onClick, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(32.dp) + ) { + Text(if (expanded) "Less" else "...more") + Spacer(Modifier.width(2.dp)) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } +} + +private fun BookItem.hasOriginalMetadata(): Boolean { + return listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription).any { !it.isNullOrBlank() } || + originalSeriesIndex != null +} + +private fun BookItem.hasMetadataChanges(): Boolean { + return metadataValueChanged(title, originalTitle) || + metadataValueChanged(author, originalAuthor) || + metadataValueChanged(seriesName, originalSeriesName) || + seriesIndex != originalSeriesIndex || + metadataValueChanged(description, originalDescription) +} + +private fun BookItem.restoredOriginalMetadata(): BookItem { + return copy( + title = originalTitle?.takeIf { it.isNotBlank() } ?: displayName.substringBeforeLast('.', displayName), + author = originalAuthor, + seriesName = originalSeriesName, + seriesIndex = originalSeriesIndex, + description = originalDescription + ) +} + +private fun metadataValueChanged(current: String?, original: String?): Boolean { + return current.orEmpty().trim() != original.orEmpty().trim() +} + +private fun BookItem.seriesLabel(): String? { + val series = seriesName?.trim()?.takeIf { it.isNotBlank() } ?: return null + return seriesIndex?.takeIf { it > 0.0 }?.let { "$series #${it.formatMetadataNumber()}" } ?: series +} + +private fun BookItem.readingProgressText(): String { + val progress = progressPercentage?.coerceIn(0f, 100f) + val progressText = progress?.toDouble()?.formatMetadataNumber()?.let { "$it%" } ?: "Not started" + val chapterIndex = readerPosition?.chapterIndex + val locatorText = when { + lastPageIndex != null -> "Last page ${lastPageIndex + 1}" + chapterIndex != null -> "Chapter ${chapterIndex + 1}" + else -> null + } + return listOfNotNull(progressText, locatorText).joinToString(" - ") +} + +private fun String.toMetadataValue(): String? { + return trim().takeIf { it.isNotEmpty() } +} + +private fun String.toSeriesIndexOrNull(): Double? { + return trim() + .replace(',', '.') + .takeIf { it.isNotEmpty() } + ?.toDoubleOrNull() + ?.takeIf { it > 0.0 } +} + +private fun Double.formatMetadataNumber(): String { + val whole = toLong() + return if (this == whole.toDouble()) whole.toString() else toString().trimEnd('0').trimEnd('.') +} + +private fun String.looksLikeHtml(): Boolean { + return contains(Regex("<\\s*/?\\s*(p|br|div|span|strong|em|ul|ol|li|h[1-6]|blockquote|a|b|i)\\b", RegexOption.IGNORE_CASE)) || + contains(Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);")) +} + +private fun String.htmlToMarkdownSummary(): String { + var text = decodeHtmlEntities() + .replace(Regex("(?is)"), "") + .replace(Regex("(?is)"), "") + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?i)"), "\n\n") + .replace(Regex("(?i)]*>"), "\n- ") + .replace(Regex("(?i)"), "") + .replace(Regex("(?i)]*>"), "\n") + .replace(Regex("(?is)]*>(.*?)")) { "# ${it.groupValues[1].stripHtmlTags()}\n\n" } + .replace(Regex("(?is)]*>(.*?)")) { "## ${it.groupValues[1].stripHtmlTags()}\n\n" } + .replace(Regex("(?is)]*>(.*?)")) { "### ${it.groupValues[1].stripHtmlTags()}\n\n" } + .replace(Regex("(?is)<(strong|b)\\b[^>]*>(.*?)")) { "**${it.groupValues[2].stripHtmlTags()}**" } + .replace(Regex("(?is)<(em|i)\\b[^>]*>(.*?)")) { "*${it.groupValues[2].stripHtmlTags()}*" } + .replace(Regex("(?is)]*>(.*?)")) { + it.groupValues[1].stripHtmlTags().lines().joinToString("\n") { line -> "> $line" } + "\n\n" + } + .replace(Regex("(?is)]*>(.*?)")) { it.groupValues[1].stripHtmlTags() } + .stripHtmlTags() + .decodeHtmlEntities() + + text = text + .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") + .replace(Regex(" *\\n *"), "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + return text +} + +private fun String.stripHtmlTags(): String { + return replace(Regex("<[^>]+>"), " ") +} + +private fun String.decodeHtmlEntities(): String { + return replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(Regex("&#x([0-9a-fA-F]+);")) { match -> + match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty() + } + .replace(Regex("&#(\\d+);")) { match -> + match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt new file mode 100644 index 0000000..2470351 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt @@ -0,0 +1,2408 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticBlock +import com.aryan.reader.paginatedreader.SemanticFlexContainer +import com.aryan.reader.paginatedreader.SemanticHeader +import com.aryan.reader.paginatedreader.SemanticImage +import com.aryan.reader.paginatedreader.SemanticList +import com.aryan.reader.paginatedreader.SemanticListItem +import com.aryan.reader.paginatedreader.SemanticMath +import com.aryan.reader.paginatedreader.SemanticParagraph +import com.aryan.reader.paginatedreader.SemanticSpacer +import com.aryan.reader.paginatedreader.SemanticTable +import com.aryan.reader.paginatedreader.SemanticTextBlock +import com.aryan.reader.paginatedreader.SemanticWrappingBlock +import com.aryan.reader.shared.HighlightColor +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import com.aryan.reader.shared.reader.logSharedReaderDiagnostic +import kotlin.math.roundToInt + +enum class SharedNativeReaderSelectionAction { + DEFINE, + SEARCH, + SPEAK +} + +data class SharedNativeReaderLinkClick( + val href: String, + val chapterIndex: Int?, + val text: String? +) + +internal data class SharedNativeReaderTextSelection( + val chapterIndex: Int, + val pageIndex: Int, + val startOffset: Int, + val endOffset: Int, + val text: String, + val startPageIndex: Int = pageIndex, + val endPageIndex: Int = pageIndex, + val startBlockIndex: Int = -1, + val endBlockIndex: Int = -1, + val startBlockCharOffset: Int = startOffset, + val endBlockCharOffset: Int = endOffset, + val startLocalOffset: Int = 0, + val endLocalOffset: Int = endOffset - startOffset, + val startBaseCfi: String? = null, + val endBaseCfi: String? = null, + val rect: Rect = Rect.Zero, + val textPerBlock: Map = emptyMap() +) { + val cfi: String + get() = if (!startBaseCfi.isNullOrBlank() && !endBaseCfi.isNullOrBlank()) { + "${startBaseCfi}:${startLocalOffset}|${endBaseCfi}:${endLocalOffset}" + } else { + "desktop:$chapterIndex:$startOffset:$endOffset" + } +} + +private data class SharedNativeSelectionBlockKey( + val pageIndex: Int, + val blockIndex: Int, + val blockCharOffset: Int +) { + val stableKey: String get() = "$pageIndex:$blockIndex:$blockCharOffset" +} + +private data class SharedNativeTextBlockDescriptor( + val chapterIndex: Int, + val pageIndex: Int, + val blockIndex: Int, + val blockCharOffset: Int, + val baseCfi: String?, + val textStartOffset: Int, + val text: String +) { + val key: SharedNativeSelectionBlockKey + get() = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) +} + +private data class SharedNativeTextLayoutInfo( + val descriptor: SharedNativeTextBlockDescriptor, + val layout: TextLayoutResult, + val coordinates: LayoutCoordinates +) + +private data class SharedNativeTextPosition( + val descriptor: SharedNativeTextBlockDescriptor, + val localOffset: Int +) + +private enum class SharedNativeSelectionHandle { + START, + END +} + +private object SharedNativeSelectionVectorIcons { + val Copy: ImageVector = vector( + name = "SharedNativeSelectionCopy", + pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640L720,160L360,160L360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800L640,800L640,880L200,880Z" + ) + val Define: ImageVector = vector( + name = "SharedNativeSelectionDefine", + pathData = "M480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664L840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268L120,664Q155,652 189.5,646Q224,640 260,640Q307,640 351.5,650.5Q396,661 440,682Z" + ) + val Speak: ImageVector = vector( + name = "SharedNativeSelectionSpeak", + pathData = "M560,828L560,746Q653,719 706.5,642Q760,565 760,466Q760,367 706.5,290Q653,213 560,186L560,104Q687,133 763.5,234Q840,335 840,466Q840,597 763.5,698Q687,799 560,828ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,292Q612,317 646,364.5Q680,412 680,466Q680,520 646,567.5Q612,615 560,640Z" + ) + val Search: ImageVector = vector( + name = "SharedNativeSelectionSearch", + pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" + ) + val Clear: ImageVector = vector( + name = "SharedNativeSelectionClear", + pathData = "M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" + ) + val Teardrop: ImageVector = vector( + name = "SharedNativeSelectionTeardrop", + pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" + ) + + private fun vector(name: String, pathData: String): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + }.build() + } +} + +@Composable +fun SharedNativePaginatedReader( + renderPlan: ReaderContentRenderPlan.NativePaginatedPages, + readerFontFamily: FontFamily, + searchHighlight: Color, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + modifier: Modifier = Modifier, + enabledSelectionActions: Set = emptySet(), + onCopyText: (String) -> Unit = {}, + onSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { _, _ -> }, + onHighlightCreated: (UserHighlight) -> Unit = {}, + onHighlightSelected: (String) -> Unit = {}, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit = {}, + imageContent: (@Composable (SemanticImage, Modifier) -> Unit)? = null +) { + val visiblePages = renderPlan.visiblePages + val firstPage = visiblePages.firstOrNull() + var activeSelection by remember(renderPlan.navigationTarget.requestId) { + mutableStateOf(null) + } + var selectionGestureActive by remember(renderPlan.navigationTarget.requestId) { + mutableStateOf(false) + } + var selectionHandleDragging by remember(renderPlan.navigationTarget.requestId) { + mutableStateOf(false) + } + fun updateActiveSelection(selection: SharedNativeReaderTextSelection?) { + activeSelection = selection + if (selection == null) { + selectionGestureActive = false + selectionHandleDragging = false + } + } + val visiblePageIndices = remember(visiblePages) { visiblePages.map { it.pageIndex } } + val selectionLayouts = remember(renderPlan.navigationTarget.requestId, visiblePageIndices) { + mutableStateMapOf() + } + var readerCoordinates by remember(renderPlan.navigationTarget.requestId) { + mutableStateOf(null) + } + val density = LocalDensity.current + LaunchedEffect(visiblePageIndices) { + val selection = activeSelection + if (selection != null && selection.pageIndex !in visiblePageIndices) { + updateActiveSelection(null) + } + } + LaunchedEffect(firstPage?.pageIndex, renderPlan.navigationTarget.requestId) { + firstPage?.let { page -> + onVisiblePageChanged( + page.pageIndex, + renderPlan.navigationTarget.locator ?: page.toNativeReaderLocator() + ) + } + } + + if (visiblePages.isEmpty()) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text("No page content", color = renderPlan.foreground.copy(alpha = 0.68f)) + } + return + } + + val selectionHighlight = MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) + Box( + modifier = modifier.onGloballyPositioned { readerCoordinates = it } + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(renderPlan.background), + contentAlignment = Alignment.Center + ) { + val pageGap = 28.dp + val horizontalMargin = renderPlan.settings.resolvedHorizontalMargin.dp + val configuredContentWidth = renderPlan.settings.pageWidth.dp + val pageOuterWidth = if (visiblePages.size > 1) { + val availablePageOuterWidth = ((maxWidth - pageGap).coerceAtLeast(1.dp)) / 2f + val availableContentWidth = (availablePageOuterWidth - (horizontalMargin * 2f)).coerceAtLeast(1.dp) + minOf(availableContentWidth, configuredContentWidth) + (horizontalMargin * 2f) + } else { + val availableContentWidth = (maxWidth - (horizontalMargin * 2f)).coerceAtLeast(1.dp) + minOf(availableContentWidth, configuredContentWidth) + (horizontalMargin * 2f) + } + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(pageGap, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + visiblePages.forEach { page -> + SharedNativePaginatedPage( + page = page, + renderPlan = renderPlan, + readerFontFamily = readerFontFamily, + searchHighlight = searchHighlight, + selectionHighlight = selectionHighlight, + activeSelection = activeSelection, + onSelectionChange = ::updateActiveSelection, + onSelectionGestureActiveChange = { selectionGestureActive = it }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent, + modifier = Modifier + .width(pageOuterWidth) + .fillMaxHeight() + ) + } + } + } + activeSelection?.let { selection -> + arrayOf(SharedNativeSelectionHandle.START, SharedNativeSelectionHandle.END).forEach { handle -> + SharedNativeSelectionHandleView( + selection = selection, + handle = handle, + selectionLayouts = selectionLayouts.values, + readerCoordinates = readerCoordinates, + onDragActiveChange = { selectionHandleDragging = it }, + onDrag = { windowPosition -> + val currentSelection = activeSelection + if (currentSelection != null) { + sharedNativeSelectionWithHandleMoved( + selection = currentSelection, + handle = handle, + windowPosition = windowPosition, + layouts = selectionLayouts.values + )?.let(::updateActiveSelection) + } + }, + modifier = Modifier.align(Alignment.TopStart) + ) + } + if (!selectionGestureActive && !selectionHandleDragging) { + SharedNativeSelectionMenu( + selection = selection, + highlightPalette = renderPlan.highlightPalette.sanitized().colors, + enabledSelectionActions = enabledSelectionActions, + background = renderPlan.background, + foreground = renderPlan.foreground, + onCopy = { + onCopyText(selection.text) + updateActiveSelection(null) + }, + onSelectionAction = { action -> + onSelectionAction(action, selection.text) + updateActiveSelection(null) + }, + onHighlight = { color -> + onHighlightCreated(sharedNativeReaderHighlightForSelection(selection, color)) + updateActiveSelection(null) + }, + onDismiss = { updateActiveSelection(null) }, + modifier = Modifier + .align(Alignment.TopStart) + .offset { + sharedNativeSelectionMenuOffset( + selection = selection, + readerCoordinates = readerCoordinates, + density = density + ) + } + ) + } + } + } +} + +@Composable +private fun SharedNativePaginatedPage( + page: ReaderPage, + renderPlan: ReaderContentRenderPlan.NativePaginatedPages, + readerFontFamily: FontFamily, + searchHighlight: Color, + selectionHighlight: Color, + activeSelection: SharedNativeReaderTextSelection?, + onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, + onSelectionGestureActiveChange: (Boolean) -> Unit, + onHighlightSelected: (String) -> Unit, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, + selectionLayouts: MutableMap, + imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, + modifier: Modifier = Modifier +) { + val settings = renderPlan.settings + val fallbackTextAlign = settings.textAlign.toComposeTextAlign() + val visibleHighlights = renderPlan.highlights.visibleInPage(page) + val blocks = page.semanticBlocks + var contentFit by remember(page.pageIndex, blocks) { mutableStateOf(null) } + val blockLayouts = remember(page.pageIndex, blocks) { mutableStateMapOf() } + var layoutVersion by remember(page.pageIndex, blocks) { mutableStateOf(0) } + var lastPageFitLogSignature by remember(page.pageIndex, blocks) { mutableStateOf(null) } + + LaunchedEffect( + contentFit, + layoutVersion, + blocks.size, + page.pageIndex, + page.chapterIndex, + settings.fontSize, + settings.lineSpacing, + settings.paragraphSpacing + ) { + val content = contentFit ?: return@LaunchedEffect + if (blocks.isEmpty() || blockLayouts.size < blocks.size) return@LaunchedEffect + val contentTopPx = content.rootTopPx + val contentHeightPx = content.heightPx + val orderedFits = blocks.indices.mapNotNull { index -> blockLayouts[index] } + if (orderedFits.size < blocks.size) return@LaunchedEffect + + val usedPx = orderedFits.maxOfOrNull { fit -> + fit.relativeBottomPx(contentTopPx) + } ?: return@LaunchedEffect + val remainingPx = contentHeightPx - usedPx + if (remainingPx >= 0) return@LaunchedEffect + + val signature = buildString { + append(page.pageIndex) + append(':') + append(contentHeightPx) + append(':') + append(usedPx) + orderedFits.forEach { fit -> + append(':') + append(fit.index) + append(',') + append(fit.relativeTopPx(contentTopPx)) + append(',') + append(fit.heightPx) + } + } + if (signature != lastPageFitLogSignature) { + lastPageFitLogSignature = signature + logSharedReaderDiagnostic(EpubPageFitLogTag) { + "page_fit layer=rendered_overflow page=${page.pageIndex + 1} chapter=${page.chapterIndex} " + + "usedPx=$usedPx contentPx=$contentHeightPx remainingPx=$remainingPx " + + "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=${blocks.size} " + + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} " + + "tail=\"${orderedFits.renderedPageFitTail(contentTopPx)}\"" + } + } + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(4.dp), + color = renderPlan.background, + contentColor = renderPlan.foreground, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, renderPlan.foreground.copy(alpha = 0.14f)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = settings.resolvedHorizontalMargin.dp, + vertical = settings.resolvedVerticalMargin.dp + ) + .onGloballyPositioned { coordinates -> + val nextFit = SharedNativeContentFit( + rootTopPx = coordinates.positionInRoot().y.roundToInt(), + heightPx = coordinates.size.height + ) + if (contentFit != nextFit) { + contentFit = nextFit + } + }, + verticalArrangement = Arrangement.Top + ) { + if (blocks.isEmpty()) { + SharedNativeInteractiveText( + text = page.text.toReaderAnnotatedString( + searchQuery = renderPlan.searchQuery, + searchHighlight = searchHighlight, + absoluteStartOffset = page.startOffset, + highlights = visibleHighlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight + ), + page = page, + textBlock = SharedNativeTextBlockDescriptor( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + blockIndex = -1, + blockCharOffset = page.startOffset, + baseCfi = null, + textStartOffset = page.startOffset, + text = page.text + ), + textStartOffset = page.startOffset, + color = renderPlan.foreground, + textAlign = fallbackTextAlign, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = settings.fontSize.sp, + lineHeight = (settings.fontSize * settings.lineSpacing).sp, + fontFamily = readerFontFamily + ).withAndroidPaginationTextMetrics(), + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + fitLabel = SharedNativeTextFitLabel( + page = page, + blockIndex = -1, + kind = "plain", + sourceRange = "${page.startOffset}..${page.endOffset}", + textChars = page.text.length + ) + ) + } else { + SharedSemanticBlockStack( + blocks = blocks, + page = page, + foreground = renderPlan.foreground, + searchQuery = renderPlan.searchQuery, + searchHighlight = searchHighlight, + highlights = visibleHighlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = readerFontFamily, + settings = settings, + includeTrailingBottomMargin = false, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent, + onBlockLaidOut = { fit -> + if (blockLayouts[fit.index] != fit) { + blockLayouts[fit.index] = fit + layoutVersion += 1 + } + } + ) + } + } + } +} + +@Composable +private fun SharedNativeSelectionMenu( + @Suppress("UNUSED_PARAMETER") + selection: SharedNativeReaderTextSelection, + highlightPalette: List, + enabledSelectionActions: Set, + background: Color, + foreground: Color, + onCopy: () -> Unit, + onSelectionAction: (SharedNativeReaderSelectionAction) -> Unit, + onHighlight: (HighlightColor) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val menuBackground = background.blendWith(foreground, foregroundWeight = 0.08f) + val borderColor = foreground.copy(alpha = 0.18f) + val hoverIconBackground = foreground.copy(alpha = 0.09f) + val iconColor = foreground.copy(alpha = 0.86f) + val actions = buildList { + add(SharedNativeSelectionMenuAction("Copy", SharedNativeSelectionVectorIcons.Copy, onCopy)) + if (SharedNativeReaderSelectionAction.DEFINE in enabledSelectionActions) { + add( + SharedNativeSelectionMenuAction( + "Define", + SharedNativeSelectionVectorIcons.Define, + { onSelectionAction(SharedNativeReaderSelectionAction.DEFINE) } + ) + ) + } + if (SharedNativeReaderSelectionAction.SPEAK in enabledSelectionActions) { + add( + SharedNativeSelectionMenuAction( + "Speak", + SharedNativeSelectionVectorIcons.Speak, + { onSelectionAction(SharedNativeReaderSelectionAction.SPEAK) } + ) + ) + } + if (SharedNativeReaderSelectionAction.SEARCH in enabledSelectionActions) { + add( + SharedNativeSelectionMenuAction( + "Search", + SharedNativeSelectionVectorIcons.Search, + { onSelectionAction(SharedNativeReaderSelectionAction.SEARCH) } + ) + ) + } + add(SharedNativeSelectionMenuAction("Clear", SharedNativeSelectionVectorIcons.Clear, onDismiss)) + } + Surface( + modifier = modifier, + shape = RoundedCornerShape(14.dp), + color = menuBackground, + contentColor = foreground, + tonalElevation = 0.dp, + shadowElevation = 18.dp, + border = BorderStroke(1.dp, borderColor) + ) { + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(max = 300.dp) + .padding(bottom = 6.dp) + ) { + if (highlightPalette.isNotEmpty()) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + highlightPalette.forEach { color -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(color.color) + .border( + width = 1.dp, + color = borderColor, + shape = CircleShape + ) + .clickable { onHighlight(color) } + ) + } + } + HorizontalDivider(color = foreground.copy(alpha = 0.12f)) + } + Column( + modifier = Modifier + .padding(start = 8.dp, top = 6.dp, end = 8.dp, bottom = 2.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + actions.chunked(3).forEach { rowActions -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + rowActions.forEach { action -> + SharedNativeSelectionIconButton( + action = action, + iconColor = iconColor, + iconBackground = hoverIconBackground, + foreground = foreground + ) + } + } + } + } + } + } +} + +private data class SharedNativeSelectionMenuAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit +) + +@Composable +private fun SharedNativeSelectionIconButton( + action: SharedNativeSelectionMenuAction, + iconColor: Color, + iconBackground: Color, + foreground: Color +) { + Column( + modifier = Modifier + .width(78.dp) + .height(58.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable { action.onClick() } + .padding(horizontal = 4.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically) + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(iconBackground), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = action.icon, + contentDescription = action.label, + tint = iconColor, + modifier = Modifier.size(18.dp) + ) + } + Text( + text = action.label, + color = foreground, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.SemiBold + ) + ) + } +} + +@Composable +private fun SharedNativeSelectionHandleView( + selection: SharedNativeReaderTextSelection, + handle: SharedNativeSelectionHandle, + selectionLayouts: Collection, + readerCoordinates: LayoutCoordinates?, + onDragActiveChange: (Boolean) -> Unit, + onDrag: (Offset) -> Unit, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val handleOffset = sharedNativeSelectionHandleOffset( + selection = selection, + handle = handle, + layouts = selectionLayouts, + readerCoordinates = readerCoordinates, + density = density + ) ?: return + val handleColor = MaterialTheme.colorScheme.primary + var handleCoordinates by remember(handle) { mutableStateOf(null) } + Box( + modifier = modifier + .offset { handleOffset } + .size(28.dp) + .onGloballyPositioned { handleCoordinates = it } + .pointerInput(handle) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + onDragActiveChange(true) + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + handleCoordinates + ?.takeIf { it.isAttached } + ?.let { coordinates -> onDrag(coordinates.localToWindow(change.position)) } + change.consume() + } + } finally { + onDragActiveChange(false) + } + } + }, + contentAlignment = Alignment.TopCenter + ) { + Icon( + imageVector = SharedNativeSelectionVectorIcons.Teardrop, + contentDescription = if (handle == SharedNativeSelectionHandle.START) { + "Adjust selection start" + } else { + "Adjust selection end" + }, + tint = handleColor, + modifier = Modifier + .size(22.dp) + .graphicsLayer { + rotationZ = if (handle == SharedNativeSelectionHandle.START) 28f else -28f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } +} + +@Composable +private fun SharedNativeInteractiveText( + text: AnnotatedString, + page: ReaderPage, + textBlock: SharedNativeTextBlockDescriptor, + textStartOffset: Int, + color: Color, + textAlign: TextAlign, + style: TextStyle, + onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, + onSelectionGestureActiveChange: (Boolean) -> Unit, + onHighlightSelected: (String) -> Unit, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, + selectionLayouts: MutableMap, + modifier: Modifier = Modifier, + fitLabel: SharedNativeTextFitLabel? = null +) { + var textLayoutResult by remember(text) { mutableStateOf(null) } + var textCoordinates by remember(text) { mutableStateOf(null) } + var lastTextClipLogSignature by remember(text) { mutableStateOf(null) } + var dragAnchorOffset by remember(text) { mutableStateOf(null) } + val viewConfiguration = LocalViewConfiguration.current + val textBlockKey = textBlock.key.stableKey + DisposableEffect(textBlockKey, selectionLayouts) { + onDispose { + selectionLayouts.remove(textBlockKey) + } + } + LaunchedEffect(textLayoutResult, textCoordinates, textBlock, textBlockKey) { + val layout = textLayoutResult ?: return@LaunchedEffect + val coordinates = textCoordinates ?: return@LaunchedEffect + selectionLayouts[textBlockKey] = SharedNativeTextLayoutInfo( + descriptor = textBlock, + layout = layout, + coordinates = coordinates + ) + } + LaunchedEffect(textLayoutResult, textCoordinates, fitLabel) { + val layout = textLayoutResult ?: return@LaunchedEffect + val coordinates = textCoordinates ?: return@LaunchedEffect + val label = fitLabel ?: return@LaunchedEffect + val boxHeightPx = coordinates.size.height + val layoutHeightPx = layout.size.height + val lastLineBottomPx = if (layout.lineCount > 0) { + layout.getLineBottom(layout.lineCount - 1).roundToInt() + } else { + layoutHeightPx + } + val clipPx = maxOf(layoutHeightPx, lastLineBottomPx) - boxHeightPx + if (clipPx <= 1) return@LaunchedEffect + val signature = "${label.page.pageIndex}:${label.blockIndex}:$boxHeightPx:$layoutHeightPx:$lastLineBottomPx" + if (signature == lastTextClipLogSignature) return@LaunchedEffect + lastTextClipLogSignature = signature + logSharedReaderDiagnostic(EpubPageFitLogTag) { + "page_fit layer=text_clip page=${label.page.pageIndex + 1} chapter=${label.page.chapterIndex} " + + "block=${label.blockIndex} kind=${label.kind} boxPx=$boxHeightPx layoutPx=$layoutHeightPx " + + "lastLineBottomPx=$lastLineBottomPx clipPx=$clipPx lines=${layout.lineCount} " + + "range=${label.sourceRange} textChars=${label.textChars}" + } + } + Text( + text = text, + color = color, + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { textCoordinates = it } + .pointerInput(text) { + detectTapGestures( + onPress = { + onSelectionGestureActiveChange(true) + try { + tryAwaitRelease() + } finally { + onSelectionGestureActiveChange(false) + } + }, + onLongPress = { offset -> + val layout = textLayoutResult ?: return@detectTapGestures + val charOffset = layout.getOffsetForPosition(offset) + .coerceIn(0, text.text.length) + val boundary = layout.getWordBoundary(charOffset) + val range = sharedNativeReaderTrimmedWordRange( + text = text.text, + start = boundary.start, + end = boundary.end + ) ?: return@detectTapGestures + onSelectionChange( + sharedNativeReaderSelectionBetween( + start = SharedNativeTextPosition(textBlock, range.start), + end = SharedNativeTextPosition(textBlock, range.end), + layouts = selectionLayouts.values + ) + ) + }, + onTap = { offset -> + val layout = textLayoutResult ?: return@detectTapGestures + val charOffset = layout.getOffsetForPosition(offset) + .coerceIn(0, text.text.length) + text.stringAnnotationAt(ReaderNativeAnnotationUrl, charOffset)?.let { href -> + onSelectionChange(null) + onLinkClicked( + SharedNativeReaderLinkClick( + href = href, + chapterIndex = page.chapterIndex, + text = text.text + ) + ) + return@detectTapGestures + } + text.stringAnnotationAt(ReaderNativeAnnotationHighlight, charOffset)?.let { highlightId -> + onSelectionChange(null) + onHighlightSelected(highlightId) + return@detectTapGestures + } + onSelectionChange(null) + } + ) + } + .pointerInput(text) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + onSelectionGestureActiveChange(true) + val layout = textLayoutResult + if (layout != null) { + val charOffset = layout.getOffsetForPosition(offset) + .coerceIn(0, text.text.length) + val boundary = layout.getWordBoundary(charOffset) + val range = sharedNativeReaderTrimmedWordRange( + text = text.text, + start = boundary.start, + end = boundary.end + ) + if (range != null) { + dragAnchorOffset = range.start + onSelectionChange( + sharedNativeReaderSelectionBetween( + start = SharedNativeTextPosition(textBlock, range.start), + end = SharedNativeTextPosition(textBlock, range.end), + layouts = selectionLayouts.values + ) + ) + } + } + }, + onDrag = { change, _ -> + val layout = textLayoutResult + val anchor = dragAnchorOffset + if (layout != null && anchor != null) { + val current = textCoordinates?.let { coordinates -> + sharedNativeReaderTextPositionAtWindow( + windowPosition = coordinates.localToWindow(change.position), + layouts = selectionLayouts.values + ) + } ?: SharedNativeTextPosition( + descriptor = textBlock, + localOffset = layout.getOffsetForPosition(change.position) + .coerceIn(0, text.text.length) + ) + onSelectionChange( + sharedNativeReaderSelectionBetween( + start = SharedNativeTextPosition(textBlock, anchor), + end = current, + layouts = selectionLayouts.values + ) + ) + } + change.consume() + }, + onDragEnd = { + dragAnchorOffset = null + onSelectionGestureActiveChange(false) + }, + onDragCancel = { + dragAnchorOffset = null + onSelectionGestureActiveChange(false) + } + ) + } + .pointerInput(textBlockKey, text) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val layout = textLayoutResult ?: return@awaitEachGesture + val coordinates = textCoordinates ?: return@awaitEachGesture + val anchorOffset = layout.getOffsetForPosition(down.position) + .coerceIn(0, text.text.length) + val anchor = SharedNativeTextPosition(textBlock, anchorOffset) + val touchSlopSquared = viewConfiguration.touchSlop * viewConfiguration.touchSlop + var selecting = false + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + val dx = change.position.x - down.position.x + val dy = change.position.y - down.position.y + if (!selecting && dx * dx + dy * dy >= touchSlopSquared) { + selecting = true + onSelectionGestureActiveChange(true) + } + if (selecting) { + val latestCoordinates = textCoordinates ?: coordinates + val windowPosition = latestCoordinates.localToWindow(change.position) + val current = sharedNativeReaderTextPositionAtWindow( + windowPosition = windowPosition, + layouts = selectionLayouts.values + ) ?: SharedNativeTextPosition( + descriptor = textBlock, + localOffset = layout.getOffsetForPosition(change.position) + .coerceIn(0, text.text.length) + ) + onSelectionChange( + sharedNativeReaderSelectionBetween( + start = anchor, + end = current, + layouts = selectionLayouts.values + ) + ) + change.consume() + } + } + } finally { + if (selecting) { + onSelectionGestureActiveChange(false) + } + } + } + }, + textAlign = textAlign, + style = style, + onTextLayout = { textLayoutResult = it } + ) +} + +private fun ReaderPage.toNativeReaderLocator(): ReaderLocator { + return ReaderLocator( + chapterIndex = chapterIndex, + pageIndex = pageIndex, + startOffset = startOffset, + endOffset = endOffset, + textQuote = text.replace(Regex("\\s+"), " ").trim().take(160), + cfi = "desktop:$chapterIndex:$startOffset:$endOffset" + ) +} + +private fun String.toReaderAnnotatedString( + searchQuery: String, + searchHighlight: Color, + absoluteStartOffset: Int, + highlights: List, + activeSelection: SharedNativeReaderTextSelection?, + selectionHighlight: Color +): AnnotatedString { + val normalized = searchQuery.trim() + return buildAnnotatedString { + append(this@toReaderAnnotatedString) + highlights.forEach { highlight -> + applyHighlightToTextRange( + highlight = highlight, + textStartOffset = absoluteStartOffset, + textLength = this@toReaderAnnotatedString.length + ) + } + applySelectionToTextRange( + selection = activeSelection, + textStartOffset = absoluteStartOffset, + textLength = this@toReaderAnnotatedString.length, + color = selectionHighlight + ) + if (normalized.length >= 2) { + var startIndex = 0 + while (startIndex < this@toReaderAnnotatedString.length) { + val index = this@toReaderAnnotatedString.indexOf(normalized, startIndex, ignoreCase = true) + if (index < 0) break + addStyle( + style = SpanStyle(background = searchHighlight), + start = index, + end = index + normalized.length + ) + startIndex = index + normalized.length + } + } + } +} + +@Composable +private fun SharedSemanticBlockStack( + blocks: List, + page: ReaderPage, + foreground: Color, + searchQuery: String, + searchHighlight: Color, + highlights: List, + activeSelection: SharedNativeReaderTextSelection?, + selectionHighlight: Color, + fallbackTextAlign: TextAlign, + fallbackFontFamily: FontFamily, + settings: ReaderSettings, + includeTrailingBottomMargin: Boolean, + onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, + onSelectionGestureActiveChange: (Boolean) -> Unit, + onHighlightSelected: (String) -> Unit, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, + selectionLayouts: MutableMap, + imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, + onBlockLaidOut: ((SharedNativeBlockFit) -> Unit)? = null +) { + var previous: SemanticBlock? = null + blocks.forEachIndexed { index, block -> + SharedSemanticBlockView( + block = block, + page = page, + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + marginTop = block.collapsedTopMarginDp(previous, settings), + marginBottom = if (includeTrailingBottomMargin && index == blocks.lastIndex) { + block.effectiveBottomMarginDp(settings) + } else { + 0.dp + }, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent, + layoutIndex = index, + onBlockLaidOut = onBlockLaidOut + ) + previous = block + } +} + +@Composable +private fun SharedSemanticBlockView( + block: SemanticBlock, + page: ReaderPage, + foreground: Color, + searchQuery: String, + searchHighlight: Color, + highlights: List, + activeSelection: SharedNativeReaderTextSelection?, + selectionHighlight: Color, + fallbackTextAlign: TextAlign, + fallbackFontFamily: FontFamily, + settings: ReaderSettings, + marginTop: Dp, + marginBottom: Dp, + onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, + onSelectionGestureActiveChange: (Boolean) -> Unit, + onHighlightSelected: (String) -> Unit, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, + selectionLayouts: MutableMap, + imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, + layoutIndex: Int? = null, + onBlockLaidOut: ((SharedNativeBlockFit) -> Unit)? = null +) { + val modifier = Modifier + .fillMaxWidth() + .padding( + start = block.style.blockStyle.margin.left.safeDp(), + top = marginTop, + end = block.style.blockStyle.margin.right.safeDp(), + bottom = marginBottom + ) + .then( + if (block.style.blockStyle.backgroundColor.isSpecified) { + Modifier.background(block.style.blockStyle.backgroundColor, RoundedCornerShape(4.dp)) + } else { + Modifier + } + ) + .padding( + start = block.style.blockStyle.padding.left.safeDp(), + top = block.style.blockStyle.padding.top.safeDp(), + end = block.style.blockStyle.padding.right.safeDp(), + bottom = block.style.blockStyle.padding.bottom.safeDp() + ) + val measuredModifier = if (layoutIndex != null && onBlockLaidOut != null) { + Modifier + .onGloballyPositioned { coordinates -> + onBlockLaidOut(block.toSharedNativeBlockFit(layoutIndex, coordinates)) + } + .then(modifier) + } else { + modifier + } + + when (block) { + is SemanticHeader -> { + SharedSemanticTextView( + block = block, + page = page, + modifier = measuredModifier, + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + fontWeight = FontWeight.Bold, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts + ) + } + + is SemanticParagraph -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) + is SemanticListItem -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) + is SemanticTextBlock -> SharedSemanticTextView(block, page, measuredModifier, foreground, searchQuery, searchHighlight, highlights, activeSelection, selectionHighlight, fallbackTextAlign, fallbackFontFamily, settings, onSelectionChange = onSelectionChange, onSelectionGestureActiveChange = onSelectionGestureActiveChange, onHighlightSelected = onHighlightSelected, onLinkClicked = onLinkClicked, selectionLayouts = selectionLayouts) + + is SemanticList -> { + Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { + var previous: SemanticBlock? = null + block.items.forEachIndexed { index, item -> + Row( + modifier = Modifier.padding( + top = item.collapsedTopMarginDp(previous, settings), + bottom = if (index == block.items.lastIndex) item.effectiveBottomMarginDp(settings) else 0.dp + ), + verticalAlignment = Alignment.Top + ) { + val markerModifier = Modifier + .width(SharedNativeListItemMarkerAreaWidthDp.dp) + .padding(end = SharedNativeListItemMarkerEndPaddingDp.dp) + Text( + text = if (block.isOrdered) "${index + 1}." else "\u2022", + color = foreground, + modifier = markerModifier, + textAlign = TextAlign.End, + style = item.renderedTextStyle( + settings = settings, + fallbackFontFamily = fallbackFontFamily, + fallbackTextAlign = TextAlign.End + ) + ) + SharedSemanticTextView( + block = item, + page = page, + modifier = Modifier.weight(1f), + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts + ) + } + previous = item + } + } + } + + is SemanticFlexContainer -> { + Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { + SharedSemanticBlockStack( + blocks = block.children, + page = page, + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + includeTrailingBottomMargin = true, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent + ) + } + } + + is SemanticWrappingBlock -> { + Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { + SharedSemanticBlockStack( + blocks = listOf(block.floatedImage) + block.paragraphsToWrap, + page = page, + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + includeTrailingBottomMargin = true, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent + ) + } + } + + is SemanticTable -> { + Column(modifier = measuredModifier, verticalArrangement = Arrangement.Top) { + block.rows.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { cell -> + Column(modifier = Modifier.weight(cell.colspan.toFloat().coerceAtLeast(1f))) { + SharedSemanticBlockStack( + blocks = cell.content, + page = page, + foreground = foreground, + searchQuery = searchQuery, + searchHighlight = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + fallbackTextAlign = fallbackTextAlign, + fallbackFontFamily = fallbackFontFamily, + settings = settings, + includeTrailingBottomMargin = true, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + imageContent = imageContent + ) + } + } + } + } + } + } + + is SemanticImage -> { + SharedNativeImageBlock( + block = block, + foreground = foreground, + settings = settings, + imageContent = imageContent, + modifier = measuredModifier + ) + } + + is SemanticMath -> { + Text( + text = block.altText ?: "Equation", + color = foreground, + modifier = measuredModifier, + style = MaterialTheme.typography.bodyMedium + ) + } + + is SemanticSpacer -> Spacer(measuredModifier.height(if (block.isExplicitLineBreak) 8.dp else 16.dp)) + } +} + +@Composable +private fun SharedNativeImageBlock( + block: SemanticImage, + foreground: Color, + settings: ReaderSettings, + imageContent: (@Composable (SemanticImage, Modifier) -> Unit)?, + modifier: Modifier = Modifier +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = block.imageContentAlignment() + ) { + val imageModifier = Modifier.sharedNativeImageSize(block, settings, maxWidth) + if (imageContent != null) { + imageContent(block, imageModifier) + } else { + Text( + text = block.altText?.takeIf { it.isNotBlank() } ?: block.path.substringAfterLast('/').substringAfterLast('\\'), + color = foreground.copy(alpha = 0.7f), + modifier = imageModifier, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +private fun SemanticImage.imageContentAlignment(): Alignment { + val style = style.blockStyle + return when { + style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd + style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart + else -> Alignment.Center + } +} + +@Composable +private fun Modifier.sharedNativeImageSize( + block: SemanticImage, + settings: ReaderSettings, + maxWidth: Dp +): Modifier { + val density = LocalDensity.current + val style = block.style.blockStyle + val imageScale = settings.imageScale.coerceIn(0.5f, 2f) + val scaledSize = sharedNativeImageRenderSizeDp( + block = block, + density = density, + maxWidth = maxWidth, + imageScale = imageScale + ) + + return this + .then( + if (scaledSize != null) { + Modifier + .width(scaledSize.first) + .height(scaledSize.second) + } else if (style.width.isPositiveSpecified()) { + Modifier.width(style.width) + } else { + Modifier.fillMaxWidth() + } + ) + .then( + if (scaledSize == null && style.maxWidth.isPositiveSpecified()) { + Modifier.widthIn(max = style.maxWidth) + } else { + Modifier + } + ) + .then( + if (scaledSize == null) { + val fallbackHeight = style.height.takeIfPositiveSpecified() + ?: with(density) { (settings.fontSize * 8f).sp.toDp() } + Modifier.height(fallbackHeight) + } else { + Modifier + } + ) +} + +private fun sharedNativeImageRenderSizeDp( + block: SemanticImage, + density: Density, + maxWidth: Dp, + imageScale: Float +): Pair? { + val intrinsicWidth = block.intrinsicWidth + val intrinsicHeight = block.intrinsicHeight + if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) { + return null + } + + val style = block.style.blockStyle + val aspectRatio = intrinsicHeight / intrinsicWidth + val maxWidthPx = with(density) { maxWidth.toPx() } + val baseWidthPx = with(density) { + if (style.width.isPositiveSpecified()) style.width.toPx() else maxWidth.toPx() + } + + var scaledWidthPx = baseWidthPx * imageScale + if (style.maxWidth.isPositiveSpecified()) { + scaledWidthPx = scaledWidthPx.coerceAtMost(with(density) { style.maxWidth.toPx() } * imageScale) + } + scaledWidthPx = scaledWidthPx.coerceAtMost(maxWidthPx) + + return with(density) { + scaledWidthPx.toDp() to (scaledWidthPx * aspectRatio).toDp() + } +} + +private data class SharedNativeContentFit( + val rootTopPx: Int, + val heightPx: Int +) + +private data class SharedNativeTextFitLabel( + val page: ReaderPage, + val blockIndex: Int, + val kind: String, + val sourceRange: String, + val textChars: Int +) + +private data class SharedNativeBlockFit( + val index: Int, + val kind: String, + val blockIndex: Int, + val sourceRange: String, + val rootTopPx: Int, + val heightPx: Int +) { + fun relativeTopPx(contentTopPx: Int): Int = rootTopPx - contentTopPx + + fun relativeBottomPx(contentTopPx: Int): Int = relativeTopPx(contentTopPx) + heightPx + + fun format(contentTopPx: Int): String { + val topPx = relativeTopPx(contentTopPx) + val bottomPx = topPx + heightPx + return "#$index:$kind(block=$blockIndex,top=$topPx,height=$heightPx,bottom=$bottomPx,range=$sourceRange)" + } +} + +private fun SemanticBlock.toSharedNativeBlockFit( + index: Int, + coordinates: LayoutCoordinates +): SharedNativeBlockFit { + return SharedNativeBlockFit( + index = index, + kind = sharedNativeKindName(), + blockIndex = blockIndex, + sourceRange = sharedNativeSourceRangeLabel(), + rootTopPx = coordinates.positionInRoot().y.roundToInt(), + heightPx = coordinates.size.height + ) +} + +private fun List.renderedPageFitTail(contentTopPx: Int): String { + return takeLast(EpubPageFitTailBlockCount).joinToString("|") { it.format(contentTopPx) } +} + +private fun SemanticBlock.sharedNativeKindName(): String { + return when (this) { + is SemanticTextBlock -> when (this) { + is SemanticHeader -> "header" + is SemanticParagraph -> "paragraph" + is SemanticListItem -> "list_item" + else -> "text" + } + is SemanticList -> "list" + is SemanticTable -> "table" + is SemanticFlexContainer -> "flex" + is SemanticWrappingBlock -> "wrapping" + is SemanticImage -> "image" + is SemanticMath -> "math" + is SemanticSpacer -> "spacer" + } +} + +private fun SemanticBlock.sharedNativeSourceRangeLabel(): String { + return when (this) { + is SemanticTextBlock -> { + val start = startCharOffsetInSource + "$start..${start + text.length}" + } + else -> cfi?.takeIf { it.isNotBlank() } + ?: elementId?.takeIf { it.isNotBlank() } + ?: "-" + }.sharedNativeLogPreview(maxLength = 80) +} + +private fun String.sharedNativeLogPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} + +@Composable +private fun SharedSemanticTextView( + block: SemanticTextBlock, + page: ReaderPage, + modifier: Modifier, + foreground: Color, + searchQuery: String, + searchHighlight: Color, + highlights: List, + activeSelection: SharedNativeReaderTextSelection?, + selectionHighlight: Color, + fallbackTextAlign: TextAlign, + fallbackFontFamily: FontFamily, + settings: ReaderSettings, + fontWeight: FontWeight? = null, + onSelectionChange: (SharedNativeReaderTextSelection?) -> Unit, + onSelectionGestureActiveChange: (Boolean) -> Unit, + onHighlightSelected: (String) -> Unit, + onLinkClicked: (SharedNativeReaderLinkClick) -> Unit, + selectionLayouts: MutableMap +) { + val textStyle = block.renderedTextStyle( + settings = settings, + fallbackFontFamily = fallbackFontFamily, + fallbackTextAlign = fallbackTextAlign, + fontWeight = fontWeight + ) + SharedNativeInteractiveText( + text = block.toAnnotatedString( + query = searchQuery, + highlightColor = searchHighlight, + highlights = highlights, + activeSelection = activeSelection, + selectionHighlight = selectionHighlight, + blockFontSizeSp = textStyle.fontSize.value, + pageIndex = page.pageIndex, + blockCfi = block.cfi, + blockIndex = block.blockIndex, + blockCharOffset = block.startCharOffsetInSource + ), + page = page, + textBlock = SharedNativeTextBlockDescriptor( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + blockIndex = block.blockIndex, + blockCharOffset = block.startCharOffsetInSource, + baseCfi = block.cfi, + textStartOffset = block.startCharOffsetInSource, + text = block.text + ), + textStartOffset = block.startCharOffsetInSource, + color = foreground, + modifier = modifier, + textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, + style = textStyle, + onSelectionChange = onSelectionChange, + onSelectionGestureActiveChange = onSelectionGestureActiveChange, + onHighlightSelected = onHighlightSelected, + onLinkClicked = onLinkClicked, + selectionLayouts = selectionLayouts, + fitLabel = SharedNativeTextFitLabel( + page = page, + blockIndex = block.blockIndex, + kind = block.sharedNativeKindName(), + sourceRange = block.sharedNativeSourceRangeLabel(), + textChars = block.text.length + ) + ) +} + +@Composable +private fun SemanticTextBlock.renderedTextStyle( + settings: ReaderSettings, + fallbackFontFamily: FontFamily, + fallbackTextAlign: TextAlign, + fontWeight: FontWeight? = null +): TextStyle { + val fontSize = (style.fontSize.takeIfSpecified() + ?: style.spanStyle.fontSize.takeIfSpecified()) + ?.resolveFontSizeSp(settings.fontSize.toFloat()) + ?: when (this) { + is SemanticHeader -> (settings.fontSize * headerScale(level)).sp + else -> settings.fontSize.sp + } + val lineHeight = style.paragraphStyle.lineHeight.takeIfSpecified() + ?.resolveLineHeightSp(fontSize.value) + ?: (fontSize.value * settings.lineSpacing).sp + return MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize, + lineHeight = lineHeight, + fontFamily = fallbackFontFamily, + fontWeight = fontWeight ?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight, + textAlign = style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign + ).withAndroidPaginationTextMetrics() +} + +private fun TextStyle.withAndroidPaginationTextMetrics(): TextStyle { + return copy( + lineBreak = LineBreak.Paragraph, + letterSpacing = TextUnit.Unspecified, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Proportional, + trim = LineHeightStyle.Trim.None + ) + ) +} + +private fun SemanticTextBlock.toAnnotatedString( + query: String, + highlightColor: Color, + highlights: List, + activeSelection: SharedNativeReaderTextSelection?, + selectionHighlight: Color, + blockFontSizeSp: Float, + pageIndex: Int, + blockCfi: String?, + blockIndex: Int, + blockCharOffset: Int +): AnnotatedString { + val normalized = query.trim() + return buildAnnotatedString { + append(text) + spans.forEach { span -> + val start = span.start.coerceIn(0, text.length) + val end = span.end.coerceIn(start, text.length) + if (start < end) { + addStyle(span.style.toRenderedSpanStyle(blockFontSizeSp), start, end) + span.linkHref?.takeIf { it.isNotBlank() }?.let { href -> + addStringAnnotation(ReaderNativeAnnotationUrl, href, start, end) + } + } + } + highlights.forEach { highlight -> + applyHighlightToTextRange( + highlight = highlight, + blockCfi = blockCfi, + textStartOffset = startCharOffsetInSource, + textLength = text.length, + text = text + ) + } + applySelectionToTextRange( + selection = activeSelection, + pageIndex = pageIndex, + blockIndex = blockIndex, + blockCharOffset = blockCharOffset, + textStartOffset = startCharOffsetInSource, + textLength = text.length, + color = selectionHighlight + ) + if (normalized.length >= 2) { + var startIndex = 0 + while (startIndex < text.length) { + val index = text.indexOf(normalized, startIndex, ignoreCase = true) + if (index < 0) break + addStyle(SpanStyle(background = highlightColor), index, index + normalized.length) + startIndex = index + normalized.length + } + } + } +} + +private fun TextUnit.takeIfSpecified(): TextUnit? = if (isSpecified) this else null + +private fun Color.blendWith(other: Color, foregroundWeight: Float): Color { + val weight = foregroundWeight.coerceIn(0f, 1f) + val baseWeight = 1f - weight + return Color( + red * baseWeight + other.red * weight, + green * baseWeight + other.green * weight, + blue * baseWeight + other.blue * weight, + alpha + ) +} + +private fun TextUnit.resolveFontSizeSp(baseFontSizeSp: Float): TextUnit { + return when { + isEm -> (baseFontSizeSp * value).sp + else -> value.sp + } +} + +private fun TextUnit.resolveLineHeightSp(fontSizeSp: Float): TextUnit { + return when { + isEm -> (fontSizeSp * value).sp + else -> value.sp + } +} + +private fun CssStyle.toRenderedSpanStyle(parentFontSizeSp: Float): SpanStyle { + val resolvedFontSize = (spanStyle.fontSize.takeIfSpecified() ?: fontSize.takeIfSpecified()) + ?.resolveFontSizeSp(parentFontSizeSp) + return if (resolvedFontSize == null) { + spanStyle + } else { + spanStyle.copy(fontSize = resolvedFontSize) + } +} + +private fun List.visibleInPage(page: ReaderPage): List { + return filter { highlight -> + val locator = highlight.locator + val chapterIndex = locator.chapterIndex ?: highlight.chapterIndex + val start = locator.startOffset + val end = locator.endOffset + val pageMatch = locator.pageIndex == page.pageIndex + val offsetMatch = start != null && + end != null && + start < page.endOffset && + end > page.startOffset + chapterIndex == page.chapterIndex && (pageMatch || offsetMatch) + } +} + +private fun AnnotatedString.Builder.applyHighlightToTextRange( + highlight: UserHighlight, + blockCfi: String? = null, + textStartOffset: Int, + textLength: Int, + text: String? = null +) { + val cfiRange = sharedNativeHighlightRangeInBlock( + highlight = highlight, + blockCfi = blockCfi, + textLength = textLength, + text = text + ) + if (cfiRange != null) { + addStyle( + style = SpanStyle(background = highlight.color.color.copy(alpha = 0.38f)), + start = cfiRange.start, + end = cfiRange.end + ) + addStringAnnotation(ReaderNativeAnnotationHighlight, highlight.id, cfiRange.start, cfiRange.end) + return + } + if (highlight.cfi.contains('|') || highlight.cfi.startsWith("/")) return + val start = highlight.locator.startOffset ?: return + val end = highlight.locator.endOffset ?: return + val localStart = (start - textStartOffset).coerceIn(0, textLength) + val localEnd = (end - textStartOffset).coerceIn(localStart, textLength) + if (localStart < localEnd) { + addStyle( + style = SpanStyle(background = highlight.color.color.copy(alpha = 0.38f)), + start = localStart, + end = localEnd + ) + addStringAnnotation(ReaderNativeAnnotationHighlight, highlight.id, localStart, localEnd) + } +} + +private fun AnnotatedString.Builder.applySelectionToTextRange( + selection: SharedNativeReaderTextSelection?, + pageIndex: Int? = null, + blockIndex: Int? = null, + blockCharOffset: Int? = null, + textStartOffset: Int, + textLength: Int, + color: Color +) { + if (selection == null) return + val blockLocalRange = if (pageIndex != null && blockIndex != null && blockCharOffset != null) { + sharedNativeSelectionRangeInBlock( + selection = selection, + pageIndex = pageIndex, + blockIndex = blockIndex, + blockCharOffset = blockCharOffset, + textLength = textLength + ) + } else { + null + } + val localStart: Int + val localEnd: Int + if (blockLocalRange != null) { + localStart = blockLocalRange.start + localEnd = blockLocalRange.end + } else { + if (selection.startBlockIndex >= 0 || selection.endBlockIndex >= 0) return + localStart = (selection.startOffset - textStartOffset).coerceIn(0, textLength) + localEnd = (selection.endOffset - textStartOffset).coerceIn(localStart, textLength) + } + if (localStart < localEnd) { + addStyle( + style = SpanStyle(background = color), + start = localStart, + end = localEnd + ) + } +} + +private fun AnnotatedString.stringAnnotationAt(tag: String, offset: Int): String? { + if (isEmpty()) return null + val start = offset.coerceIn(0, (length - 1).coerceAtLeast(0)) + val end = (start + 1).coerceAtMost(length) + return getStringAnnotations(tag, start, end).firstOrNull()?.item +} + +private data class SharedNativeSelectedTextRange( + val info: SharedNativeTextLayoutInfo, + val start: Int, + val end: Int +) + +private data class SharedNativeSelectionEndpoint( + val info: SharedNativeTextLayoutInfo, + val localOffset: Int +) + +private fun sharedNativeSelectionMenuOffset( + selection: SharedNativeReaderTextSelection, + readerCoordinates: LayoutCoordinates?, + density: Density +): IntOffset { + val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16) + if (selection.rect == Rect.Zero) return IntOffset(16, 16) + val centerX = (selection.rect.left + selection.rect.right) / 2f + val topLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.top)) + val bottomLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.bottom)) + val paddingPx = with(density) { 16.dp.toPx() } + val estimatedWidthPx = with(density) { 300.dp.toPx() } + val estimatedHeightPx = with(density) { 154.dp.toPx() } + val maxX = (coordinates.size.width - estimatedWidthPx - paddingPx).coerceAtLeast(paddingPx) + val x = (topLocal.x - estimatedWidthPx / 2f).coerceIn(paddingPx, maxX) + val yAbove = topLocal.y - estimatedHeightPx - paddingPx + val y = if (yAbove >= paddingPx) { + yAbove + } else { + (bottomLocal.y + paddingPx).coerceAtMost( + (coordinates.size.height - estimatedHeightPx - paddingPx).coerceAtLeast(paddingPx) + ) + } + return IntOffset(x.roundToInt(), y.roundToInt()) +} + +private fun sharedNativeSelectionHandleOffset( + selection: SharedNativeReaderTextSelection, + handle: SharedNativeSelectionHandle, + layouts: Collection, + readerCoordinates: LayoutCoordinates?, + density: Density +): IntOffset? { + val reader = readerCoordinates?.takeIf { it.isAttached } ?: return null + val endpoint = sharedNativeSelectionEndpoint(selection, handle, layouts) ?: return null + val textLength = endpoint.info.descriptor.text.length + if (textLength <= 0) return null + val safeOffset = endpoint.localOffset.coerceIn(0, textLength) + val probeStart = when (handle) { + SharedNativeSelectionHandle.START -> safeOffset.coerceIn(0, textLength - 1) + SharedNativeSelectionHandle.END -> (safeOffset - 1).coerceIn(0, textLength - 1) + } + val probeEnd = (probeStart + 1).coerceAtMost(textLength) + val localRect = runCatching { + endpoint.info.layout.getPathForRange(probeStart, probeEnd).getBounds() + }.getOrNull() ?: return null + val localX = when (handle) { + SharedNativeSelectionHandle.START -> if (safeOffset >= textLength) localRect.right else localRect.left + SharedNativeSelectionHandle.END -> if (safeOffset <= probeStart) localRect.left else localRect.right + } + val windowPosition = endpoint.info.coordinates.localToWindow(Offset(localX, localRect.bottom)) + val readerPosition = reader.windowToLocal(windowPosition) + val halfHandlePx = with(density) { 14.dp.toPx() } + return IntOffset( + x = (readerPosition.x - halfHandlePx).roundToInt(), + y = readerPosition.y.roundToInt() + ) +} + +private fun sharedNativeSelectionWithHandleMoved( + selection: SharedNativeReaderTextSelection, + handle: SharedNativeSelectionHandle, + windowPosition: Offset, + layouts: Collection +): SharedNativeReaderTextSelection? { + val moved = sharedNativeReaderTextPositionAtWindow(windowPosition, layouts) ?: return null + val opposite = sharedNativeSelectionEndpointPosition( + selection = selection, + handle = if (handle == SharedNativeSelectionHandle.START) SharedNativeSelectionHandle.END else SharedNativeSelectionHandle.START, + layouts = layouts + ) ?: return null + return if (handle == SharedNativeSelectionHandle.START) { + sharedNativeReaderSelectionBetween(moved, opposite, layouts) + } else { + sharedNativeReaderSelectionBetween(opposite, moved, layouts) + } +} + +private fun sharedNativeSelectionEndpointPosition( + selection: SharedNativeReaderTextSelection, + handle: SharedNativeSelectionHandle, + layouts: Collection +): SharedNativeTextPosition? { + val endpoint = sharedNativeSelectionEndpoint(selection, handle, layouts) ?: return null + return SharedNativeTextPosition( + descriptor = endpoint.info.descriptor, + localOffset = endpoint.localOffset.coerceIn(0, endpoint.info.descriptor.text.length) + ) +} + +private fun sharedNativeSelectionEndpoint( + selection: SharedNativeReaderTextSelection, + handle: SharedNativeSelectionHandle, + layouts: Collection +): SharedNativeSelectionEndpoint? { + val pageIndex = if (handle == SharedNativeSelectionHandle.START) { + selection.startPageIndex + } else { + selection.endPageIndex + } + val blockIndex = if (handle == SharedNativeSelectionHandle.START) { + selection.startBlockIndex + } else { + selection.endBlockIndex + } + val blockCharOffset = if (handle == SharedNativeSelectionHandle.START) { + selection.startBlockCharOffset + } else { + selection.endBlockCharOffset + } + val localOffset = if (handle == SharedNativeSelectionHandle.START) { + selection.startLocalOffset + } else { + selection.endLocalOffset + } + val key = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) + val info = layouts.firstOrNull { it.coordinates.isAttached && it.descriptor.key == key } ?: return null + return SharedNativeSelectionEndpoint(info, localOffset) +} + +private fun sharedNativeReaderTextPositionAtWindow( + windowPosition: Offset, + layouts: Collection +): SharedNativeTextPosition? { + val target = layouts + .asSequence() + .filter { it.coordinates.isAttached && it.descriptor.text.isNotEmpty() } + .minByOrNull { info -> + val rect = info.coordinates.boundsInWindow() + val dx = maxOf(rect.left - windowPosition.x, 0f, windowPosition.x - rect.right) + val dy = maxOf(rect.top - windowPosition.y, 0f, windowPosition.y - rect.bottom) + dx * dx + dy * dy + } ?: return null + val localPosition = target.coordinates.windowToLocal(windowPosition) + return SharedNativeTextPosition( + descriptor = target.descriptor, + localOffset = target.layout.getOffsetForPosition(localPosition) + .coerceIn(0, target.descriptor.text.length) + ) +} + +private fun sharedNativeReaderSelectionBetween( + start: SharedNativeTextPosition, + end: SharedNativeTextPosition, + layouts: Collection +): SharedNativeReaderTextSelection? { + if (start.descriptor.chapterIndex != end.descriptor.chapterIndex) return null + val (orderedStart, orderedEnd) = if (sharedNativeCompareTextPositions(start, end) <= 0) { + start to end + } else { + end to start + } + val selectedRanges = layouts + .asSequence() + .filter { it.coordinates.isAttached } + .filter { info -> + sharedNativeSelectionRangeInBlock( + start = orderedStart, + end = orderedEnd, + block = info.descriptor, + textLength = info.descriptor.text.length + ) != null + } + .sortedWith( + compareBy { it.descriptor.pageIndex } + .thenBy { it.descriptor.blockIndex } + .thenBy { it.descriptor.blockCharOffset } + ) + .mapNotNull { info -> + val range = sharedNativeSelectionRangeInBlock( + start = orderedStart, + end = orderedEnd, + block = info.descriptor, + textLength = info.descriptor.text.length + ) ?: return@mapNotNull null + SharedNativeSelectedTextRange(info, range.start, range.end) + } + .toMutableList() + sharedNativeTrimSelectedRanges(selectedRanges) + if (selectedRanges.isEmpty()) return null + val selectedText = selectedRanges.joinToString(" ") { range -> + range.info.descriptor.text.substring(range.start, range.end) + }.trim() + if (selectedText.isBlank()) return null + val first = selectedRanges.first() + val last = selectedRanges.last() + val startAbsoluteOffset = first.info.descriptor.blockCharOffset + first.start + val endAbsoluteOffset = last.info.descriptor.blockCharOffset + last.end + return SharedNativeReaderTextSelection( + chapterIndex = first.info.descriptor.chapterIndex, + pageIndex = first.info.descriptor.pageIndex, + startOffset = startAbsoluteOffset, + endOffset = endAbsoluteOffset, + text = selectedText, + startPageIndex = first.info.descriptor.pageIndex, + endPageIndex = last.info.descriptor.pageIndex, + startBlockIndex = first.info.descriptor.blockIndex, + endBlockIndex = last.info.descriptor.blockIndex, + startBlockCharOffset = first.info.descriptor.blockCharOffset, + endBlockCharOffset = last.info.descriptor.blockCharOffset, + startLocalOffset = first.start, + endLocalOffset = last.end, + startBaseCfi = first.info.descriptor.baseCfi, + endBaseCfi = last.info.descriptor.baseCfi, + rect = sharedNativeSelectionRect(selectedRanges), + textPerBlock = selectedRanges.associate { range -> + range.info.descriptor.key.stableKey to range.info.descriptor.text.substring(range.start, range.end) + } + ) +} + +private fun sharedNativeSelectionRangeInBlock( + start: SharedNativeTextPosition, + end: SharedNativeTextPosition, + block: SharedNativeTextBlockDescriptor, + textLength: Int +): SharedNativeReaderTextRange? { + if (sharedNativeCompareBlockToPosition(block, start) < 0) return null + if (sharedNativeCompareBlockToPosition(block, end) > 0) return null + val isStart = block.key == start.descriptor.key + val isEnd = block.key == end.descriptor.key + val localStart = if (isStart) start.localOffset else 0 + val localEnd = if (isEnd) end.localOffset else textLength + val safeStart = localStart.coerceIn(0, textLength) + val safeEnd = localEnd.coerceIn(safeStart, textLength) + return if (safeStart < safeEnd) SharedNativeReaderTextRange(safeStart, safeEnd) else null +} + +private fun sharedNativeSelectionRangeInBlock( + selection: SharedNativeReaderTextSelection, + pageIndex: Int, + blockIndex: Int, + blockCharOffset: Int, + textLength: Int +): SharedNativeReaderTextRange? { + if (selection.startBlockIndex < 0 || selection.endBlockIndex < 0) return null + val blockPosition = SharedNativeSelectionBlockKey(pageIndex, blockIndex, blockCharOffset) + val startPosition = SharedNativeSelectionBlockKey( + selection.startPageIndex, + selection.startBlockIndex, + selection.startBlockCharOffset + ) + val endPosition = SharedNativeSelectionBlockKey( + selection.endPageIndex, + selection.endBlockIndex, + selection.endBlockCharOffset + ) + if (sharedNativeCompareBlockKeys(blockPosition, startPosition) < 0) return null + if (sharedNativeCompareBlockKeys(blockPosition, endPosition) > 0) return null + val isStart = blockPosition == startPosition + val isEnd = blockPosition == endPosition + val localStart = if (isStart) selection.startLocalOffset else 0 + val localEnd = if (isEnd) selection.endLocalOffset else textLength + val safeStart = localStart.coerceIn(0, textLength) + val safeEnd = localEnd.coerceIn(safeStart, textLength) + return if (safeStart < safeEnd) SharedNativeReaderTextRange(safeStart, safeEnd) else null +} + +private fun sharedNativeCompareTextPositions( + first: SharedNativeTextPosition, + second: SharedNativeTextPosition +): Int { + val blockCompare = sharedNativeCompareBlockKeys(first.descriptor.key, second.descriptor.key) + return if (blockCompare != 0) blockCompare else first.localOffset.compareTo(second.localOffset) +} + +private fun sharedNativeCompareBlockToPosition( + block: SharedNativeTextBlockDescriptor, + position: SharedNativeTextPosition +): Int = sharedNativeCompareBlockKeys(block.key, position.descriptor.key) + +private fun sharedNativeCompareBlockKeys( + first: SharedNativeSelectionBlockKey, + second: SharedNativeSelectionBlockKey +): Int { + if (first.pageIndex != second.pageIndex) return first.pageIndex.compareTo(second.pageIndex) + if (first.blockIndex != second.blockIndex) return first.blockIndex.compareTo(second.blockIndex) + return first.blockCharOffset.compareTo(second.blockCharOffset) +} + +private fun sharedNativeTrimSelectedRanges(ranges: MutableList) { + while (ranges.isNotEmpty()) { + val first = ranges.first() + val text = first.info.descriptor.text + var start = first.start + while (start < first.end && text[start].isWhitespace()) start++ + if (start < first.end) { + if (start != first.start) ranges[0] = first.copy(start = start) + break + } + ranges.removeAt(0) + } + while (ranges.isNotEmpty()) { + val lastIndex = ranges.lastIndex + val last = ranges[lastIndex] + val text = last.info.descriptor.text + var end = last.end + while (end > last.start && text[end - 1].isWhitespace()) end-- + if (end > last.start) { + if (end != last.end) ranges[lastIndex] = last.copy(end = end) + break + } + ranges.removeAt(lastIndex) + } +} + +private fun sharedNativeSelectionRect(ranges: List): Rect { + var left = Float.POSITIVE_INFINITY + var top = Float.POSITIVE_INFINITY + var right = Float.NEGATIVE_INFINITY + var bottom = Float.NEGATIVE_INFINITY + ranges.forEach { range -> + val coordinates = range.info.coordinates + val windowRect = runCatching { + val localRect = range.info.layout.getPathForRange(range.start, range.end).getBounds() + Rect( + coordinates.localToWindow(localRect.topLeft), + coordinates.localToWindow(localRect.bottomRight) + ) + }.getOrElse { + coordinates.boundsInWindow() + } + left = minOf(left, windowRect.left, windowRect.right) + top = minOf(top, windowRect.top, windowRect.bottom) + right = maxOf(right, windowRect.left, windowRect.right) + bottom = maxOf(bottom, windowRect.top, windowRect.bottom) + } + return if (left.isFinite() && top.isFinite() && right.isFinite() && bottom.isFinite()) { + Rect(left, top, right, bottom) + } else { + Rect.Zero + } +} + +internal data class SharedNativeReaderTextRange( + val start: Int, + val end: Int +) + +internal fun sharedNativeReaderTrimmedWordRange( + text: String, + start: Int, + end: Int +): SharedNativeReaderTextRange? { + var normalizedStart = start.coerceIn(0, text.length) + var normalizedEnd = end.coerceIn(normalizedStart, text.length) + while (normalizedStart < normalizedEnd && !text[normalizedStart].isLetterOrDigit()) { + normalizedStart++ + } + while (normalizedEnd > normalizedStart && !text[normalizedEnd - 1].isLetterOrDigit()) { + normalizedEnd-- + } + return if (normalizedStart < normalizedEnd) { + SharedNativeReaderTextRange(normalizedStart, normalizedEnd) + } else { + null + } +} + +private data class SharedNativeCfiPoint( + val path: String, + val offset: Int +) + +private fun sharedNativeHighlightRangeInBlock( + highlight: UserHighlight, + blockCfi: String?, + textLength: Int, + text: String? +): SharedNativeReaderTextRange? { + val cfi = highlight.cfi.takeIf { it.contains('|') || it.startsWith("/") } ?: return null + val blockPath = blockCfi?.takeIf { it.startsWith("/") } ?: return null + val parts = cfi.split('|') + val start = parts.firstOrNull()?.sharedNativeCfiPointOrNull() ?: return null + val end = parts.lastOrNull()?.sharedNativeCfiPointOrNull() ?: start + val startMatches = sharedNativeCfiPathsEquivalent(start.path, blockPath) + val endMatches = sharedNativeCfiPathsEquivalent(end.path, blockPath) + val isIntermediate = !startMatches && !endMatches && + parts.size > 1 && + sharedNativeCfiPathStrictlyBetween(blockPath, start.path, end.path) + if (!startMatches && !endMatches && !isIntermediate) return null + + var localStart = if (startMatches) start.offset else 0 + var localEnd = if (endMatches) end.offset else textLength + if (startMatches && endMatches && localEnd < localStart) { + localStart = localEnd.also { localEnd = localStart } + } + localStart = localStart.coerceIn(0, textLength) + localEnd = localEnd.coerceIn(localStart, textLength) + if (localStart < localEnd) { + return SharedNativeReaderTextRange(localStart, localEnd) + } + + val quote = highlight.text.takeIf { it.isNotBlank() } + val blockText = text + if (quote != null && blockText != null) { + val exact = blockText.indexOf(quote, ignoreCase = false) + if (exact >= 0) return SharedNativeReaderTextRange(exact, (exact + quote.length).coerceAtMost(textLength)) + val relaxed = blockText.indexOf(quote, ignoreCase = true) + if (relaxed >= 0) return SharedNativeReaderTextRange(relaxed, (relaxed + quote.length).coerceAtMost(textLength)) + } + return null +} + +private fun String.sharedNativeCfiPointOrNull(): SharedNativeCfiPoint? { + val separator = lastIndexOf(':') + if (separator <= 0 || separator == lastIndex) return null + val path = substring(0, separator).takeIf { it.startsWith("/") } ?: return null + val offset = substring(separator + 1).toIntOrNull() ?: return null + return SharedNativeCfiPoint(path, offset) +} + +private fun sharedNativeCfiPathsEquivalent(first: String, second: String): Boolean { + val firstParts = first.split('/').filter { it.isNotEmpty() } + val secondParts = second.split('/').filter { it.isNotEmpty() } + if (firstParts == secondParts) return true + return firstParts.size == secondParts.size && + firstParts.isNotEmpty() && + firstParts.drop(1) == secondParts.drop(1) +} + +private fun sharedNativeCfiPathStrictlyBetween(candidate: String, start: String, end: String): Boolean { + val candidateParts = candidate.sharedNativeCfiNumericPathParts() ?: return false + val startParts = start.sharedNativeCfiNumericPathParts() ?: return false + val endParts = end.sharedNativeCfiNumericPathParts() ?: return false + return sharedNativeCompareCfiPathParts(candidateParts, startParts) > 0 && + sharedNativeCompareCfiPathParts(candidateParts, endParts) < 0 +} + +private fun String.sharedNativeCfiNumericPathParts(): List? { + val parts = split('/').filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + return parts.map { it.toIntOrNull() ?: return null } +} + +private fun sharedNativeCompareCfiPathParts(first: List, second: List): Int { + val length = minOf(first.size, second.size) + for (index in 0 until length) { + val comparison = first[index].compareTo(second[index]) + if (comparison != 0) return comparison + } + return first.size.compareTo(second.size) +} + +internal fun sharedNativeReaderHighlightForSelection( + selection: SharedNativeReaderTextSelection, + color: HighlightColor +): UserHighlight { + val locator = ReaderLocator( + chapterIndex = selection.chapterIndex, + pageIndex = selection.pageIndex, + startOffset = selection.startOffset, + endOffset = selection.endOffset, + textQuote = selection.text, + cfi = selection.cfi + ) + return UserHighlight( + id = "native-${selection.chapterIndex}-${selection.startPageIndex}-${selection.startBlockIndex}-${selection.startLocalOffset}-${selection.endPageIndex}-${selection.endBlockIndex}-${selection.endLocalOffset}-${color.id}", + cfi = selection.cfi, + text = selection.text, + color = color, + chapterIndex = selection.chapterIndex, + locator = locator + ) +} + +private fun headerScale(level: Int): Float { + return when (level) { + 1 -> 1.5f + 2 -> 1.35f + 3 -> 1.2f + 4 -> 1.1f + else -> 1f + } +} + +private fun Dp.safeDp(): Dp = if (isSpecified) this else 0.dp + +private fun Dp.isPositiveSpecified(): Boolean = isSpecified && this > 0.dp + +private fun Dp.takeIfPositiveSpecified(): Dp? = takeIf { it.isPositiveSpecified() } + +@Composable +private fun SemanticBlock.collapsedTopMarginDp( + previous: SemanticBlock?, + settings: ReaderSettings +): Dp { + val top = style.blockStyle.margin.top.safeDp() + return previous?.let { maxOf(it.effectiveBottomMarginDp(settings), top) } ?: top +} + +@Composable +private fun SemanticBlock.effectiveBottomMarginDp(settings: ReaderSettings): Dp { + val explicit = style.blockStyle.margin.bottom.safeDp() + if (explicit != 0.dp) return explicit + return renderedDefaultBottomSpacingDp(settings) +} + +@Composable +private fun SemanticBlock.renderedDefaultBottomSpacingDp(settings: ReaderSettings): Dp { + return when (this) { + is SemanticParagraph, + is SemanticHeader, + is SemanticList, + is SemanticTable, + is SemanticImage -> settings.renderedDefaultBlockSpacingDp() + is SemanticMath -> if (svgContent == null) settings.renderedDefaultBlockSpacingDp() else 0.dp + else -> 0.dp + } +} + +@Composable +private fun ReaderSettings.renderedDefaultBlockSpacingDp(): Dp { + val density = LocalDensity.current + return with(density) { (fontSize * paragraphSpacing).sp.toDp() } +} + +private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign { + return when (this) { + SharedReaderTextAlign.START -> TextAlign.Start + SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify + SharedReaderTextAlign.CENTER -> TextAlign.Center + } +} + +private const val ReaderNativeAnnotationUrl = "URL" +private const val ReaderNativeAnnotationHighlight = "HIGHLIGHT" +private const val EpubPageFitLogTag = "EpistemeEpubPageFit" +private const val EpubPageFitTailBlockCount = 4 +private const val SharedNativeListItemMarkerAreaWidthDp = 32 +private const val SharedNativeListItemMarkerEndPaddingDp = 8 diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt index e3b7f41..3de6fbd 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt @@ -47,12 +47,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -88,6 +86,9 @@ fun SharedOpdsScreen( onReadBook: (BookItem) -> Unit, onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit, onClearError: () -> Unit, + coverContent: @Composable (OpdsEntry, Modifier) -> Unit = { entry, coverModifier -> + SharedOpdsCoverPlaceholder(entry, coverModifier) + }, modifier: Modifier = Modifier ) { var selectedEntry by remember { mutableStateOf(null) } @@ -121,7 +122,8 @@ fun SharedOpdsScreen( onDownloadBook = onDownloadBook, onReadBook = onReadBook, onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) }, - onEntrySelected = { selectedEntry = it } + onEntrySelected = { selectedEntry = it }, + coverContent = coverContent ) } @@ -213,7 +215,8 @@ fun SharedOpdsScreen( onSearch = { query -> onSearch(query) selectedEntry = null - } + }, + coverContent = coverContent ) } } @@ -273,7 +276,8 @@ private fun SharedOpdsFeedView( onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit, onReadBook: (BookItem) -> Unit, onStreamBook: (OpdsEntry) -> Unit, - onEntrySelected: (OpdsEntry) -> Unit + onEntrySelected: (OpdsEntry) -> Unit, + coverContent: @Composable (OpdsEntry, Modifier) -> Unit ) { var showSearch by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } @@ -298,7 +302,7 @@ private fun SharedOpdsFeedView( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } if (showSearch) { - OutlinedTextField( + SharedStableOutlinedTextField( value = query, onValueChange = { query = it }, placeholder = { Text("Search catalog") }, @@ -380,12 +384,6 @@ private fun SharedOpdsFeedView( verticalArrangement = Arrangement.spacedBy(12.dp) ) { itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry -> - val nextUrl = state.currentFeed?.nextUrl - if (index == entries.lastIndex && nextUrl != null) { - LaunchedEffect(index, nextUrl) { - onLoadNextPage() - } - } if (entry.isNavigation) { SharedOpdsNavigationCard(entry, onOpenFeedUrl) } else { @@ -396,10 +394,23 @@ private fun SharedOpdsFeedView( onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) }, onReadBook = onReadBook, onStreamBook = { onStreamBook(entry) }, - onClick = { onEntrySelected(entry) } + onClick = { onEntrySelected(entry) }, + coverContent = coverContent ) } } + state.currentFeed?.nextUrl?.let { nextUrl -> + item(key = "load_more_$nextUrl") { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + OutlinedButton( + onClick = onLoadNextPage, + enabled = !state.isLoading + ) { + Text(if (state.isLoading) "Loading..." else "Load more") + } + } + } + } } } } @@ -567,7 +578,8 @@ private fun SharedOpdsBookCard( onDownloadBook: (OpdsAcquisition) -> Unit, onReadBook: (BookItem) -> Unit, onStreamBook: () -> Unit, - onClick: () -> Unit + onClick: () -> Unit, + coverContent: @Composable (OpdsEntry, Modifier) -> Unit ) { val uniqueAcquisitions = remember(entry.acquisitions) { entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } @@ -582,15 +594,7 @@ private fun SharedOpdsBookCard( modifier = Modifier.fillMaxWidth() ) { Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Box( - modifier = Modifier - .size(width = 70.dp, height = 100.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium) - } + coverContent(entry, Modifier.size(width = 70.dp, height = 100.dp)) Column(Modifier.weight(1f)) { Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) entry.author?.let { @@ -687,7 +691,8 @@ private fun SharedOpdsEntryDetailsDialog( onReadBook: (BookItem) -> Unit, onStreamBook: () -> Unit, onOpenFeedUrl: (String) -> Unit, - onSearch: (String) -> Unit + onSearch: (String) -> Unit, + coverContent: @Composable (OpdsEntry, Modifier) -> Unit ) { val uniqueAcquisitions = remember(entry.acquisitions) { entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } @@ -709,6 +714,24 @@ private fun SharedOpdsEntryDetailsDialog( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { + coverContent(entry, Modifier.size(width = 96.dp, height = 140.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + entry.series?.takeIf { it.isNotBlank() }?.let { series -> + Text( + text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + val secondary = listOfNotNull( + entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" }, + entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" }, + entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" } + ) + secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } localLibraryBook?.let { book -> Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Check, contentDescription = null) @@ -737,14 +760,6 @@ private fun SharedOpdsEntryDetailsDialog( } } } - entry.series?.takeIf { it.isNotBlank() }?.let { series -> - Text( - text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}", - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 4.dp) - ) - } if (entry.authors.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Authors", style = MaterialTheme.typography.labelLarge) @@ -767,12 +782,6 @@ private fun SharedOpdsEntryDetailsDialog( } } } - val secondary = listOfNotNull( - entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" }, - entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" }, - entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" } - ) - secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } val summary = SharedOpdsText.cleanSummary(entry.summary) if (summary.isNotBlank()) { Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) @@ -805,16 +814,17 @@ private fun SharedOpdsCatalogDialog( title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true) - OutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true) + SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true, selectionKey = catalog?.id ?: "new:title") + SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true, selectionKey = catalog?.id ?: "new:url") Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) - OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true) - OutlinedTextField( + SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true, selectionKey = catalog?.id ?: "new:username") + SharedStableOutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, singleLine = true, - visualTransformation = PasswordVisualTransformation() + visualTransformation = PasswordVisualTransformation(), + selectionKey = catalog?.id ?: "new:password" ) } }, @@ -839,3 +849,15 @@ private fun OpdsEntry.findLocalBook(localLibraryBooks: List): BookItem it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true) } } + +@Composable +private fun SharedOpdsCoverPlaceholder(entry: OpdsEntry, modifier: Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt index 9faa8b8..08d9fea 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,6 +29,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.TextFields @@ -68,12 +70,16 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -86,7 +92,9 @@ import com.aryan.reader.shared.pdf.PdfPageBounds import com.aryan.reader.shared.pdf.PdfPagePoint import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import com.aryan.reader.shared.pdf.SharedPdfInkRenderData import com.aryan.reader.shared.pdf.SharedPdfInkRenderer import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults @@ -96,8 +104,10 @@ import com.aryan.reader.shared.pdf.SharedPdfTextResizeHandle import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig import com.aryan.reader.shared.pdf.movedBy import com.aryan.reader.shared.pdf.resizedBy +import com.aryan.reader.shared.pdf.sharedPdfTextFontSizePx import com.aryan.reader.shared.pdf.sharedPdfStrokePercent import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange +import com.aryan.reader.shared.pdf.withSharedPdfTextFontSize import kotlin.math.roundToInt val SharedPdfAnnotationDefaultTools: List = listOf( @@ -110,12 +120,20 @@ val SharedPdfAnnotationDefaultTools: List = listOf( PdfInkTool.ERASER ) +private enum class SharedPdfAnnotationSettingsPanel { + PEN, + HIGHLIGHTER, + ERASER +} + @Composable fun SharedPdfAnnotationToolDock( selectedTool: PdfInkTool, selectedColor: Int, strokeWidth: Float, tools: List = SharedPdfAnnotationDefaultTools, + penPalette: List = SharedPdfAnnotationDefaults.penPalette, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, onToolSelected: (PdfInkTool) -> Unit, onColorSelected: (Int) -> Unit, onStrokeWidthChange: (Float) -> Unit, @@ -124,45 +142,101 @@ fun SharedPdfAnnotationToolDock( isHighlighterSnapEnabled: Boolean = false, onHighlighterSnapChange: (Boolean) -> Unit = {} ) { - val strokeRange = selectedTool.sharedPdfStrokeWidthRange() - val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) - val showColorPalette = selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER - val showStrokeSettings = selectedTool != PdfInkTool.TEXT - val palette = if (selectedTool.isHighlighter) { - SharedPdfAnnotationDefaults.highlighterPalette - } else { - SharedPdfAnnotationDefaults.penPalette + val availableTools = tools.distinct() + val penTools = listOf(PdfInkTool.FOUNTAIN_PEN, PdfInkTool.PEN, PdfInkTool.PENCIL) + .filter { it in availableTools } + val highlighterTools = listOf(PdfInkTool.HIGHLIGHTER, PdfInkTool.HIGHLIGHTER_ROUND) + .filter { it in availableTools } + var lastPenTool by remember { mutableStateOf(PdfInkTool.PEN) } + var lastHighlighterTool by remember { mutableStateOf(PdfInkTool.HIGHLIGHTER) } + var activeSettingsPanel by remember { mutableStateOf(null) } + + LaunchedEffect(selectedTool) { + when { + selectedTool in penTools -> lastPenTool = selectedTool + selectedTool in highlighterTools -> lastHighlighterTool = selectedTool + selectedTool != PdfInkTool.ERASER -> activeSettingsPanel = null + } } - Surface( - color = Color(0xFF1E1E1E), - contentColor = Color.White, - shape = RoundedCornerShape(24.dp), - shadowElevation = 8.dp, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Surface( + color = Color(0xFF1E1E1E), + contentColor = Color.White, + shape = RoundedCornerShape(28.dp), + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth() ) { - tools.distinct().chunked(4).forEach { rowTools -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - rowTools.forEach { tool -> - SharedPdfToolButton( - tool = tool, - selectedTool = selectedTool, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - onToolSelected = onToolSelected - ) - } + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 10.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (penTools.isNotEmpty()) { + val tool = selectedTool.takeIf { it in penTools } ?: lastPenTool.takeIf { it in penTools } ?: penTools.first() + SharedPdfToolButton( + tool = tool, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = { + onToolSelected(tool) + activeSettingsPanel = SharedPdfAnnotationSettingsPanel.PEN + } + ) } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (highlighterTools.isNotEmpty()) { + val tool = selectedTool.takeIf { it in highlighterTools } + ?: lastHighlighterTool.takeIf { it in highlighterTools } + ?: highlighterTools.first() + SharedPdfToolButton( + tool = tool, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = { + onToolSelected(tool) + activeSettingsPanel = SharedPdfAnnotationSettingsPanel.HIGHLIGHTER + } + ) + } + + if (PdfInkTool.TEXT in availableTools) { + SharedPdfToolButton( + tool = PdfInkTool.TEXT, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = { + activeSettingsPanel = null + onToolSelected(PdfInkTool.TEXT) + } + ) + } + + if (PdfInkTool.ERASER in availableTools) { + SharedPdfToolButton( + tool = PdfInkTool.ERASER, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = { + onToolSelected(PdfInkTool.ERASER) + activeSettingsPanel = SharedPdfAnnotationSettingsPanel.ERASER + } + ) + } + + Box( + modifier = Modifier + .height(22.dp) + .width(1.dp) + .background(Color.White.copy(alpha = 0.18f)) + ) + DockCircleButton(onClick = onUndo) { Icon( imageVector = Icons.AutoMirrored.Filled.Undo, @@ -180,48 +254,161 @@ fun SharedPdfAnnotationToolDock( ) } } + } - if (showColorPalette) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - palette.forEach { argb -> - val selected = argb == selectedColor - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color(argb).copy(alpha = 1f)) - .border( - width = if (selected) 2.dp else 1.dp, - color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), - shape = CircleShape - ) - .clickable { onColorSelected(argb) } + activeSettingsPanel?.let { panel -> + val toolsForPanel = when (panel) { + SharedPdfAnnotationSettingsPanel.PEN -> penTools + SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> highlighterTools + SharedPdfAnnotationSettingsPanel.ERASER -> listOf(PdfInkTool.ERASER).filter { it in availableTools } + } + if (toolsForPanel.isNotEmpty()) { + val panelTool = when (panel) { + SharedPdfAnnotationSettingsPanel.PEN -> selectedTool.takeIf { it in penTools } ?: lastPenTool + SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> selectedTool.takeIf { it in highlighterTools } ?: lastHighlighterTool + SharedPdfAnnotationSettingsPanel.ERASER -> PdfInkTool.ERASER + } + SharedPdfAnnotationToolSettingsPanel( + panel = panel, + tools = toolsForPanel, + selectedTool = panelTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + penPalette = penPalette, + highlighterPalette = highlighterPalette, + onToolSelected = { tool -> + when (panel) { + SharedPdfAnnotationSettingsPanel.PEN -> lastPenTool = tool + SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> lastHighlighterTool = tool + SharedPdfAnnotationSettingsPanel.ERASER -> Unit + } + onToolSelected(tool) + }, + onColorSelected = onColorSelected, + onStrokeWidthChange = onStrokeWidthChange, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = onHighlighterSnapChange + ) + } + } + } +} + +@Composable +private fun SharedPdfAnnotationToolSettingsPanel( + panel: SharedPdfAnnotationSettingsPanel, + tools: List, + selectedTool: PdfInkTool, + selectedColor: Int, + strokeWidth: Float, + penPalette: List, + highlighterPalette: List, + onToolSelected: (PdfInkTool) -> Unit, + onColorSelected: (Int) -> Unit, + onStrokeWidthChange: (Float) -> Unit, + isHighlighterSnapEnabled: Boolean, + onHighlighterSnapChange: (Boolean) -> Unit +) { + val isEraser = panel == SharedPdfAnnotationSettingsPanel.ERASER + val isHighlighter = panel == SharedPdfAnnotationSettingsPanel.HIGHLIGHTER + val effectiveTool = if (isEraser) PdfInkTool.ERASER else selectedTool + val strokeRange = effectiveTool.sharedPdfStrokeWidthRange() + val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + val activeColor = if (isEraser) Color.White else Color(selectedColor) + + Surface( + color = Color(0xFF1E1E1E), + contentColor = Color.White, + shape = RoundedCornerShape(24.dp), + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isEraser) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(104.dp), + contentAlignment = Alignment.Center + ) { + val diameter = (sliderValue * 800f).coerceIn(10f, 128f).dp + Canvas(modifier = Modifier.size(diameter)) { + drawCircle( + color = Color.White.copy(alpha = 0.3f), + radius = size.minDimension / 2f + ) + drawCircle( + color = Color.White, + radius = size.minDimension / 2f, + style = Stroke(width = 2.dp.toPx()) + ) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + tools.forEach { tool -> + SharedPdfToolButton( + tool = tool, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = onToolSelected ) } } } - if (showStrokeSettings) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}", - color = Color.White.copy(alpha = 0.86f), - style = MaterialTheme.typography.labelMedium - ) - Slider( - value = sliderValue, - onValueChange = onStrokeWidthChange, - valueRange = strokeRange, - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = if (selectedTool == PdfInkTool.ERASER) Color.White else Color(selectedColor).copy(alpha = 1f), - inactiveTrackColor = Color.White.copy(alpha = 0.18f) + if (!isEraser) { + SharedPdfInkColorPalette( + colors = if (isHighlighter) { + highlighterPalette.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + } else { + penPalette.ifEmpty { SharedPdfAnnotationDefaults.penPalette } + }, + selectedColor = selectedColor, + matchRgbOnly = isHighlighter, + onColorSelected = { color -> + onColorSelected( + if (isHighlighter) { + color.withSharedPdfAnnotationAlpha(Color(selectedColor).alpha) + } else { + color + } ) - ) - } + } + ) } - if (selectedTool.isHighlighter) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (isEraser) { + "Eraser size ${sliderValue.sharedPdfStrokePercent(strokeRange)}" + } else { + "Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}" + }, + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.labelMedium + ) + Slider( + value = sliderValue, + onValueChange = onStrokeWidthChange, + valueRange = strokeRange, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = activeColor.copy(alpha = 1f), + inactiveTrackColor = Color.White.copy(alpha = 0.18f) + ) + ) + } + + if (isHighlighter) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -243,6 +430,158 @@ fun SharedPdfAnnotationToolDock( ) ) } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val alpha = Color(selectedColor).alpha.coerceIn(0.1f, 1f) + Text( + text = "Opacity ${(alpha * 100f).roundToInt()}", + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.labelMedium + ) + Slider( + value = alpha, + onValueChange = { nextAlpha -> + onColorSelected(selectedColor.withSharedPdfAnnotationAlpha(nextAlpha)) + }, + valueRange = 0.1f..1f, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color(selectedColor).copy(alpha = 1f), + inactiveTrackColor = Color.White.copy(alpha = 0.18f) + ) + ) + } + } + } + } +} + +@Composable +private fun SharedPdfInkColorPalette( + colors: List, + selectedColor: Int, + matchRgbOnly: Boolean, + onColorSelected: (Int) -> Unit +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + colors.forEach { argb -> + val selected = if (matchRgbOnly) { + (argb and 0x00FFFFFF) == (selectedColor and 0x00FFFFFF) + } else { + argb == selectedColor + } + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(argb).copy(alpha = 1f)) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), + shape = CircleShape + ) + .clickable { onColorSelected(argb) } + ) + } + } +} + +@Composable +fun SharedPdfHighlighterPaletteEditor( + palette: SharedPdfHighlighterPalette, + onPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + modifier: Modifier = Modifier +) { + val sanitized = palette.sanitized() + var editingSlot by remember { mutableStateOf(null) } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Highlight colors", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Tap a color to customize it.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + sanitized.colors.forEachIndexed { index, argb -> + val color = Color(argb) + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(color.copy(alpha = 1f)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = CircleShape + ) + .clickable { editingSlot = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + editingSlot?.let { slot -> + val initialColor = Color(sanitized.colors.getOrElse(slot) { SharedPdfHighlighterPalette.defaultColors.first() }).copy(alpha = 1f) + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = "Highlight color ${slot + 1}", + onDismiss = { editingSlot = null }, + onSave = { color -> + onPaletteChange( + sanitized.withColorAt( + slotIndex = slot, + colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + ) + ) + editingSlot = null + } + ) { color -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(color) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = if (color.luminance() > 0.5f) Color.Black else Color.White + ) + } + Column(modifier = Modifier.weight(1f)) { + Text("PDF highlighter", fontWeight = FontWeight.SemiBold) + Text( + "Saved with reader highlight transparency.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } } } } @@ -334,19 +673,35 @@ fun SharedPdfTextBoxEditorOverlay( val moveHandleWidthPx = with(density) { moveHandleWidth.toPx() } val moveHandleHeightPx = with(density) { moveHandleHeight.toPx() } val moveHandleBelow = topPx + heightPx + moveHandleHeightPx + 10f <= canvasSize.height + var textFieldValue by remember(id) { + mutableStateOf(TextFieldValue(text, TextRange(text.length))) + } LaunchedEffect(id, style) { focusRequester.requestFocus() } + LaunchedEffect(id, text) { + if (text != textFieldValue.text) { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + } + } + + val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize) + Box(modifier = modifier.fillMaxSize()) { BasicTextField( - value = text, - onValueChange = onTextChange, + value = textFieldValue, + onValueChange = { nextValue -> + textFieldValue = nextValue + if (nextValue.text != text) { + onTextChange(nextValue.text) + } + }, textStyle = TextStyle( color = textColor, - fontSize = style.fontSize.sp, - lineHeight = (style.fontSize * 1.25f).sp, + fontSize = with(density) { fontSizePx.toSp() }, + lineHeight = with(density) { (fontSizePx * 1.25f).toSp() }, fontWeight = if (style.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (style.isItalic) FontStyle.Italic else FontStyle.Normal, fontFamily = sharedPdfFontFamily(style.fontName ?: style.fontPath), @@ -540,7 +895,7 @@ fun SharedPdfTextStyleControls( selected = style.fontSize.toInt() == size.toInt(), selectedBackground = selectedBackground, unselectedBackground = unselectedBackground, - onClick = { onStyleChange(style.copy(fontSize = size)) } + onClick = { onStyleChange(style.withSharedPdfTextFontSize(size)) } ) { Text( text = size.toInt().toString(), @@ -612,6 +967,7 @@ fun SharedPdfTextStyleControls( } } +@Suppress("UNUSED_PARAMETER") @Composable fun SharedPdfAnnotationOverlay( annotations: List, @@ -620,7 +976,10 @@ fun SharedPdfAnnotationOverlay( activeTool: PdfInkTool = PdfInkTool.PEN, activeStrokeColorArgb: Int = 0xFF1976D2.toInt(), activeStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, - selectedAnnotationId: String? = null + selectedAnnotationId: String? = null, + eraserPosition: Offset? = null, + showEraserIndicator: Boolean = false, + eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth ) { if (canvasSize.width <= 0 || canvasSize.height <= 0) return val density = LocalDensity.current @@ -628,22 +987,14 @@ fun SharedPdfAnnotationOverlay( Box(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) { annotations.forEach { annotation -> - val isSelected = annotation.matchesSelectedAnnotation(selectedAnnotationId) - if (isSelected && annotation.kind == PdfAnnotationKind.INK) { - SharedPdfInkRenderer.createRenderData(annotation, canvasSize)?.let { renderData -> - drawInkRenderData(renderData, selectedOutline = true) - } - } - when (annotation.kind) { PdfAnnotationKind.HIGHLIGHT -> { val highlightBounds = annotation.boundsList.ifEmpty { listOfNotNull(annotation.bounds) } highlightBounds.forEach { bounds -> drawRect( - color = Color(annotation.colorArgb), + color = Color(annotation.colorArgb).copy(alpha = SharedPdfAndroidHighlightColors.RenderAlpha), topLeft = bounds.topLeft(canvasSize), size = bounds.size(canvasSize), - blendMode = BlendMode.Multiply ) } } @@ -662,19 +1013,9 @@ fun SharedPdfAnnotationOverlay( } } } - - if (isSelected && annotation.kind != PdfAnnotationKind.INK) { - val bounds = annotation.bounds ?: annotation.boundsList.firstOrNull() ?: return@forEach - drawRect( - color = Color(0xFF64B5F6), - topLeft = bounds.topLeft(canvasSize), - size = bounds.size(canvasSize), - style = Stroke(width = 2f) - ) - } } - if (activeStroke.size > 1) { + if (activeStroke.isNotEmpty()) { val activeAnnotation = SharedPdfAnnotation( id = "active", pageIndex = 0, @@ -686,6 +1027,22 @@ fun SharedPdfAnnotationOverlay( ) SharedPdfInkRenderer.createRenderData(activeAnnotation, canvasSize)?.let(::drawInkRenderData) } + + if (showEraserIndicator && eraserPosition != null) { + val radius = SharedPdfInkRenderer.effectiveStrokeWidthPx(eraserStrokeWidth, canvasSize) + .coerceAtLeast(8.dp.toPx()) + drawCircle( + color = Color.White.copy(alpha = 0.3f), + radius = radius, + center = eraserPosition + ) + drawCircle( + color = Color.Black, + radius = radius, + center = eraserPosition, + style = Stroke(width = 1.dp.toPx()) + ) + } } annotations @@ -696,17 +1053,18 @@ fun SharedPdfAnnotationOverlay( val topPx = bounds.top * canvasSize.height val widthPx = ((bounds.right - bounds.left) * canvasSize.width).coerceAtLeast(24f) val heightPx = ((bounds.bottom - bounds.top) * canvasSize.height).coerceAtLeast(18f) + val fontSizePx = annotation.sharedPdfTextFontSizePx(canvasSize) Text( text = annotation.text, color = Color(annotation.colorArgb), - fontSize = annotation.fontSize.sp, - lineHeight = (annotation.fontSize * 1.25f).sp, + fontSize = with(density) { fontSizePx.toSp() }, + lineHeight = with(density) { (fontSizePx * 1.25f).toSp() }, fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (annotation.isItalic) FontStyle.Italic else FontStyle.Normal, fontFamily = annotation.sharedPdfTextFontFamily(), textDecoration = annotation.textDecoration, overflow = TextOverflow.Ellipsis, - maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, annotation.fontSize, widthPx), + maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, fontSizePx, widthPx), modifier = Modifier .offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) } .width(with(density) { widthPx.toDp() }) @@ -942,6 +1300,7 @@ private fun SharedPdfPenIcon( drawMatteCylinder(animatedBodyColor, collarRect) drawMarkerHead(animatedBodyColor, tipRect) } + PdfInkTool.NONE, PdfInkTool.TEXT, PdfInkTool.ERASER -> Unit } @@ -1298,14 +1657,13 @@ private fun DrawScope.drawInkPreview( ) } -private fun SharedPdfAnnotation.matchesSelectedAnnotation(selectedAnnotationId: String?): Boolean { - if (selectedAnnotationId == null) return false - return id == selectedAnnotationId || id.startsWith("${selectedAnnotationId}_line_") -} - private val PdfInkTool.isHighlighter: Boolean get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND +private fun Int.withSharedPdfAnnotationAlpha(alpha: Float): Int { + return Color(this).copy(alpha = alpha.coerceIn(0f, 1f)).toArgb() +} + private val SharedPdfAnnotation.textDecoration: TextDecoration get() { val decorations = mutableListOf() diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt index c2b8aa8..60f2502 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt @@ -1,11 +1,21 @@ package com.aryan.reader.shared.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -14,56 +24,88 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.NavigateBefore import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Translate -import androidx.compose.material.icons.filled.VolumeUp -import androidx.compose.material3.Button import androidx.compose.material3.FilterChip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Slider +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.aryan.reader.shared.BuiltInReaderThemes import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.HighlightColor @@ -71,6 +113,7 @@ import com.aryan.reader.shared.PageInfoMode import com.aryan.reader.shared.PageInfoPosition import com.aryan.reader.shared.ReaderAiByokSettings import com.aryan.reader.shared.ReaderAiFeature +import com.aryan.reader.shared.ReaderAiResultState import com.aryan.reader.shared.ReaderAutoScrollState import com.aryan.reader.shared.ReaderContextExtractor import com.aryan.reader.shared.ReaderExtrasState @@ -100,23 +143,21 @@ import com.aryan.reader.shared.reader.PaginatedReaderState import com.aryan.reader.shared.reader.ReaderBookmark import com.aryan.reader.shared.reader.ReaderEngine import com.aryan.reader.shared.reader.ReaderHtmlDocumentBuilder +import com.aryan.reader.shared.reader.ReaderLinkTarget +import com.aryan.reader.shared.reader.ReaderPageSpreadMode import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSearchOptions import com.aryan.reader.shared.reader.ReaderSessionState import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.ReaderSpreadLayout +import com.aryan.reader.shared.reader.SharedEpubTocEntry import com.aryan.reader.shared.reader.SharedReaderTextAlign +import com.aryan.reader.shared.reader.appearanceSignature +import com.aryan.reader.shared.reader.layoutSignature +import com.aryan.reader.shared.reader.logSharedReaderDiagnostic import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlin.math.roundToInt -data class ReaderContentNavigationTarget( - val locator: ReaderLocator?, - val requestId: Long, - val readingMode: ReaderReadingMode, - val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(), - val ttsLocator: ReaderLocator? = null, - val ttsRequestId: Long = 0L -) - @Composable fun SharedScreenScaffold( title: String, @@ -147,8 +188,9 @@ fun SharedReaderScreen( session: ReaderSessionState, readerEngine: ReaderEngine, onSessionChange: (ReaderSessionState) -> Unit, - onOpenBook: () -> Unit, - onOpenPdf: () -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + isFullscreen: Boolean = false, + onFullscreenChange: (Boolean) -> Unit = {}, toolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {}, highlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), @@ -160,8 +202,12 @@ fun SharedReaderScreen( customFonts: List = emptyList(), readerExtrasState: ReaderExtrasState = ReaderExtrasState(), aiByokSettings: ReaderAiByokSettings = ReaderAiByokSettings(), + externalLookupAvailable: Boolean = true, + cloudTtsControlsAvailable: Boolean = true, onExternalLookup: (ReaderExternalLookupAction, String) -> Unit = { _, _ -> }, onAiAction: (ReaderAiFeature, String) -> Unit = { _, _ -> }, + onAiResultDismiss: () -> Unit = {}, + onCopyText: (String) -> Unit = {}, onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit = { _, _ -> }, onCloudTtsPauseResume: () -> Unit = {}, onCloudTtsStop: () -> Unit = {}, @@ -171,11 +217,9 @@ fun SharedReaderScreen( readerCustomTextureIds: List = emptyList(), onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null, readerContent: @Composable ColumnScope.( - html: String, - background: Color, - navigationTarget: ReaderContentNavigationTarget, - highlights: List, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit + renderPlan: ReaderContentRenderPlan, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onHighlightSelected: (String) -> Unit ) -> Unit ) { val readerState = session.reader @@ -183,6 +227,7 @@ fun SharedReaderScreen( val settings = readerState.settings val byokSettings = aiByokSettings.sanitized() val background = settings.backgroundColorArgb?.toComposeColor() ?: if (settings.darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) + val foreground = settings.textColorArgb?.toComposeColor() ?: if (settings.darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) val pageInfoText = readerState.pageInfoText() val shouldShowPageInfo = settings.pageInfoMode != PageInfoMode.HIDDEN val activeTtsProgress = readerExtrasState.cloudTts.progress @@ -190,14 +235,31 @@ fun SharedReaderScreen( val activeTtsLocator = activeTtsChunk?.toLocator() val ttsRequestId = activeTtsChunk?.let { activeTtsProgress.sessionId + it.index + 1L } ?: 0L val navigationLocator = session.navigationLocator ?: session.activeSearchResult?.locator ?: readerState.currentPageLocator() + val effectiveCloudTtsAvailable = cloudTtsControlsAvailable && byokSettings.isCloudTtsAvailable + val readerFocusRequester = remember(session.reader.book.id) { FocusRequester() } + val currentIsFullscreen by rememberUpdatedState(isFullscreen) + val currentOnFullscreenChange by rememberUpdatedState(onFullscreenChange) + var selectedHighlightId by remember(session.reader.book.id) { mutableStateOf(null) } + var sidebarNavigationHighlightId by remember(session.reader.book.id) { mutableStateOf(null) } + val selectedHighlight = remember(session.highlights, selectedHighlightId) { + session.highlights.firstOrNull { it.id == selectedHighlightId } + } fun dispatch(action: ReaderAction) { onSessionChange(session.reduce(action, readerEngine)) } + fun dispatchAll(actions: List) { + onSessionChange(actions.fold(session) { state, action -> state.reduce(action, readerEngine) }) + } + fun setFullscreen(enabled: Boolean) { + onFullscreenChange(enabled) + } val workspaceModel = epubReaderWorkspaceModel( session = session, toolbarPreferences = toolbarPreferences, extrasState = readerExtrasState, - aiAvailable = byokSettings.areReaderAiFeaturesAvailable + aiAvailable = byokSettings.areReaderAiFeaturesAvailable, + cloudTtsAvailable = effectiveCloudTtsAvailable, + externalLookupAvailable = externalLookupAvailable ) LaunchedEffect( @@ -213,16 +275,65 @@ fun SharedReaderScreen( dispatch(ReaderAction.NextPage) } + LaunchedEffect(session.reader.book.id, settings.readingMode, readerState.currentPageIndex) { + runCatching { readerFocusRequester.requestFocus() } + } + + LaunchedEffect(isFullscreen, session.reader.book.id) { + repeat(if (isFullscreen) 4 else 1) { attempt -> + delay(if (attempt == 0) 80L else 120L) + runCatching { readerFocusRequester.requestFocus() } + } + } + + val readerPopupActive = selectedHighlight != null || readerExtrasState.aiResult.hasContent + LaunchedEffect(readerPopupActive, session.reader.book.id) { + if (!readerPopupActive) { + delay(120L) + runCatching { readerFocusRequester.requestFocus() } + } + } + + DisposableEffect(session.reader.book.id) { + onDispose { + if (currentIsFullscreen) { + currentOnFullscreenChange(false) + } + } + } + ReaderWorkspaceShell( model = workspaceModel, title = readerState.book.title, subtitle = listOfNotNull(readerState.book.author, page?.chapterTitle).joinToString(" - "), progressLabel = "${readerState.progress.toInt()}%", + onReturnToLibrary = onReturnToLibrary, + isFullscreen = isFullscreen, + onFullscreenChange = ::setFullscreen, + isBookmarked = session.currentBookmark != null, + onToggleBookmark = { dispatch(ReaderAction.ToggleBookmark) }, + onSearchAction = { dispatch(ReaderAction.SearchOpened) }, + topSearchBar = if (session.isSearchActive) { + { + SharedReaderSearchTopBar( + session = session, + onReaderAction = { action -> dispatch(action) } + ) + } + } else { + null + }, modifier = Modifier .fillMaxSize() + .focusRequester(readerFocusRequester) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false when { + isFullscreen && event.key == Key.Escape -> { + setFullscreen(false) + true + } + event.key == Key.DirectionRight || event.key == Key.PageDown -> { dispatch(ReaderAction.NextPage) true @@ -234,17 +345,17 @@ fun SharedReaderScreen( } event.key == Key.MoveHome -> { - dispatch(ReaderAction.GoToPage(0)) + dispatch(ReaderAction.JumpToPage(0)) true } event.key == Key.MoveEnd -> { - dispatch(ReaderAction.GoToPage(readerState.pages.lastIndex)) + dispatch(ReaderAction.JumpToPage(readerState.pages.lastIndex)) true } event.isCtrlPressed && event.key == Key.G -> { - dispatch(ReaderAction.NextSearchResult) + dispatch(ReaderAction.JumpToNextSearchResult) true } @@ -257,61 +368,31 @@ fun SharedReaderScreen( } } .focusable(), - topActions = { - TextButton(onClick = onOpenBook) { - Text("Open Book") - } - TextButton(onClick = onOpenPdf) { - Text("Open PDF") - } - SharedReaderQuickActions( - toolbarPreferences = toolbarPreferences, - bottom = false, - isBookmarked = session.currentBookmark != null, - isDarkMode = settings.darkMode, - isSearchActive = session.isSearchActive, - onToggleBookmark = { dispatch(ReaderAction.ToggleBookmark) }, - onToggleTheme = { dispatch(ReaderAction.SettingsChanged(settings.copy(darkMode = !settings.darkMode))) }, - onToggleSearch = { - dispatch(if (session.isSearchActive) ReaderAction.SearchClosed else ReaderAction.SearchOpened) - }, - onExternalLookup = onExternalLookup, - onAiAction = onAiAction, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, - session = session, - extrasState = readerExtrasState, - aiByokSettings = byokSettings - ) - }, - leftSidebar = { + leftSidebar = { _ -> SharedReaderSidebar( session = session, - onSearchChange = { dispatch(ReaderAction.SearchChanged(it)) }, - onPreviousSearchResult = { dispatch(ReaderAction.PreviousSearchResult) }, - onNextSearchResult = { dispatch(ReaderAction.NextSearchResult) }, - onOpenSearch = { dispatch(ReaderAction.SearchOpened) }, - onCloseSearch = { dispatch(ReaderAction.SearchClosed) }, - onToggleSearchResultsPanel = { dispatch(ReaderAction.SearchResultsPanelToggled) }, - onSearchOptionsChange = { dispatch(ReaderAction.SearchOptionsChanged(it)) }, - onGoToChapter = { dispatch(ReaderAction.GoToChapter(it)) }, - onGoToBookmark = { dispatch(ReaderAction.GoToLocator(it.locator)) }, - onGoToSearchResult = { dispatch(ReaderAction.GoToSearchResult(it)) }, - toolbarPreferences = toolbarPreferences, + readerEngine = readerEngine, + sections = workspaceModel.leftSections, + onGoToChapter = { dispatch(ReaderAction.JumpToChapter(it)) }, + onGoToLocator = { dispatch(ReaderAction.JumpToLocator(it)) }, + onGoToBookmark = { dispatch(ReaderAction.JumpToLocator(it.locator)) }, + onGoToHighlight = { + sidebarNavigationHighlightId = it.id + selectedHighlightId = null + dispatch(ReaderAction.JumpToLocator(it.locator)) + }, + onEditHighlight = { + selectedHighlightId = it.id + }, highlightPalette = highlightPalette, - onHighlightPaletteChange = onHighlightPaletteChange, - onGoToHighlight = { dispatch(ReaderAction.GoToLocator(it.locator)) }, onHighlightColorChange = { highlight, color -> dispatch(ReaderAction.HighlightUpdated(highlight.id, color = color)) }, - onHighlightNoteChange = { highlight, note -> - dispatch(ReaderAction.HighlightUpdated(highlight.id, note = note)) - }, - onHighlightDelete = { highlight -> - dispatch(ReaderAction.HighlightDeleted(highlight.id)) + onDeleteHighlight = { + dispatch(ReaderAction.HighlightDeleted(it.id)) + if (selectedHighlightId == it.id) { + selectedHighlightId = null + } } ) }, @@ -319,21 +400,20 @@ fun SharedReaderScreen( SharedReaderControlPanel( session = session, toolbarPreferences = toolbarPreferences, - onToolbarPreferencesChange = onToolbarPreferencesChange, onPickCustomFont = onPickCustomFont, customFonts = customFonts, extrasState = readerExtrasState, aiByokSettings = byokSettings, - onExternalLookup = onExternalLookup, + cloudTtsControlsAvailable = cloudTtsControlsAvailable, onAiAction = onAiAction, onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, onAutoScrollChange = onAutoScrollChange, ttsReplacementPreferences = ttsReplacementPreferences, ttsReplacementBookId = ttsReplacementBookId ?: session.reader.book.title, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = onImportReaderTexture, onReaderAction = { action -> dispatch(action) } @@ -341,80 +421,113 @@ fun SharedReaderScreen( }, bottomBar = { Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (toolbarPreferences.isVisible(ReaderTool.SLIDER)) { - SharedReaderPageSlider( - session = session, - onPageNumberChange = { pageNumber -> dispatch(ReaderAction.GoToPageNumber(pageNumber)) } + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + logReaderGapChrome( + layer = "bottom_nav_surface", + bounds = coordinates.boundsInWindow(), + details = "sliderVisible=${toolbarPreferences.isVisible(ReaderTool.SLIDER)} pageInfoBottom=${shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.BOTTOM}" ) + }, + shape = RoundedCornerShape(6.dp), + color = background, + contentColor = foreground, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, foreground.copy(alpha = 0.12f)) + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + val showJumpHistory = !session.isSearchActive && session.shouldShowJumpHistory + if (showJumpHistory) { + SharedReaderJumpHistoryBar( + session = session, + onBack = { dispatch(ReaderAction.JumpBack) }, + onForward = { dispatch(ReaderAction.JumpForward) }, + onClear = { dispatch(ReaderAction.JumpHistoryCleared) } + ) + HorizontalDivider(color = foreground.copy(alpha = 0.12f)) } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Button( - enabled = readerState.canGoPrevious, - onClick = { dispatch(ReaderAction.PreviousPage) } - ) { - Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = null) - Text("Previous") - } - Spacer(Modifier.weight(1f)) - if (shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.BOTTOM) { - Text(pageInfoText) - } - Spacer(Modifier.weight(1f)) - Button( - enabled = readerState.canGoNext, - onClick = { dispatch(ReaderAction.NextPage) } - ) { - Text("Next") - Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = null) - } - } - SharedReaderQuickActions( - toolbarPreferences = toolbarPreferences, - bottom = true, - isBookmarked = session.currentBookmark != null, - isDarkMode = settings.darkMode, - isSearchActive = session.isSearchActive, - onToggleBookmark = { dispatch(ReaderAction.ToggleBookmark) }, - onToggleTheme = { dispatch(ReaderAction.SettingsChanged(settings.copy(darkMode = !settings.darkMode))) }, - onToggleSearch = { - dispatch(if (session.isSearchActive) ReaderAction.SearchClosed else ReaderAction.SearchOpened) - }, - onExternalLookup = onExternalLookup, - onAiAction = onAiAction, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, + SharedReaderCompactNavigation( session = session, - extrasState = readerExtrasState, - aiByokSettings = byokSettings + showSlider = toolbarPreferences.isVisible(ReaderTool.SLIDER), + canGoPrevious = readerState.canGoPrevious, + canGoNext = readerState.canGoNext, + pageInfoText = if (shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoText else null, + onPrevious = { dispatch(ReaderAction.PreviousPage) }, + onNext = { dispatch(ReaderAction.NextPage) }, + onPageNumberChange = { pageNumber -> dispatch(ReaderAction.GoToPageNumber(pageNumber)) }, + contentColor = foreground ) } } + }, + fullscreenBottomBar = { + SharedReaderFullscreenNavigation( + session = session, + onPrevious = { dispatch(ReaderAction.PreviousPage) }, + onNext = { dispatch(ReaderAction.NextPage) }, + onPageNumberChange = { pageNumber -> dispatch(ReaderAction.GoToPageNumber(pageNumber)) }, + onJumpBack = { dispatch(ReaderAction.JumpBack) }, + onJumpForward = { dispatch(ReaderAction.JumpForward) }, + onClearJumpHistory = { dispatch(ReaderAction.JumpHistoryCleared) }, + backgroundColor = background, + contentColor = foreground + ) } ) { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.TOP) { + LaunchedEffect(sidebarNavigationHighlightId) { + if (sidebarNavigationHighlightId != null) { + delay(1_200) + sidebarNavigationHighlightId = null + } + } + Column( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + logReaderGapChrome( + layer = "reader_content_column", + bounds = coordinates.boundsInWindow(), + details = "mode=${settings.readingMode} columnGap=12 pageInfoTop=${shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.TOP}" + ) + }, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!isFullscreen && shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.TOP) { Text(pageInfoText, color = MaterialTheme.colorScheme.onSurfaceVariant) } - val html = if (settings.readingMode == ReaderReadingMode.VERTICAL) { - remember( + val documentLayoutSignature = settings.layoutSignature() + val textureDataUri = remember(settings.textureId) { + settings.textureId?.let(readerTextureDataUri) + } + val navigationTarget = ReaderContentNavigationTarget( + locator = navigationLocator, + requestId = session.navigationRequestId, + readingMode = settings.readingMode, + autoScroll = readerExtrasState.autoScroll.sanitized(), + ttsLocator = activeTtsLocator, + ttsRequestId = ttsRequestId + ) + val renderPlan = if (settings.readingMode == ReaderReadingMode.VERTICAL) { + val appearanceSignature = settings.appearanceSignature() + val appearanceScript = remember(appearanceSignature, textureDataUri) { + ReaderHtmlDocumentBuilder.appearanceUpdateScript( + settings = settings, + textureDataUri = textureDataUri + ) + } + // Keep the initial locator in the document so its first position report is not the top of the book. + val html = remember( readerState.book, - settings, + documentLayoutSignature, session.searchQuery, session.searchOptions, - highlightPalette, readerState.pages, byokSettings.areReaderAiFeaturesAvailable, - byokSettings.isCloudTtsAvailable + effectiveCloudTtsAvailable, + externalLookupAvailable ) { ReaderHtmlDocumentBuilder.verticalDocument( book = readerState.book, @@ -423,55 +536,543 @@ fun SharedReaderScreen( searchOptions = session.searchOptions, highlights = emptyList(), highlightPalette = highlightPalette, - navigationLocator = null, + navigationLocator = navigationLocator, pages = readerState.pages, readerAiFeaturesEnabled = byokSettings.areReaderAiFeaturesAvailable, - cloudTtsEnabled = byokSettings.isCloudTtsAvailable, - textureDataUri = settings.textureId?.let(readerTextureDataUri) + cloudTtsEnabled = effectiveCloudTtsAvailable, + externalLookupEnabled = externalLookupAvailable, + textureDataUri = textureDataUri ) } + ReaderContentRenderPlan.WebDocument( + html = html, + appearanceScript = appearanceScript, + background = background, + foreground = foreground, + navigationTarget = navigationTarget, + highlights = session.highlights + ) } else { - remember( - readerState.book, - page, - settings, - session.searchQuery, - session.searchOptions, - session.highlights, - highlightPalette, - navigationLocator, - byokSettings.areReaderAiFeaturesAvailable, - byokSettings.isCloudTtsAvailable + ReaderContentRenderPlan.NativePaginatedPages( + visiblePages = readerState.visiblePages, + settings = settings, + searchQuery = session.searchQuery, + searchOptions = session.searchOptions, + highlightPalette = highlightPalette, + background = background, + foreground = foreground, + navigationTarget = navigationTarget, + highlights = session.highlights + ) + } + readerContent( + renderPlan, + { pageIndex, locator -> dispatch(ReaderAction.VisiblePageChanged(pageIndex, locator)) }, + { highlightId -> + if (sidebarNavigationHighlightId == highlightId) { + sidebarNavigationHighlightId = null + } else { + selectedHighlightId = highlightId + } + } + ) + } + SharedReaderSearchOverlay( + session = session, + onResultClick = { index -> + dispatchAll( + listOf( + ReaderAction.JumpToSearchResult(index), + ReaderAction.SearchResultsPanelToggled + ) + ) + }, + onShowResults = { dispatch(ReaderAction.SearchResultsPanelToggled) }, + onPrevious = { dispatch(ReaderAction.JumpToPreviousSearchResult) }, + onNext = { dispatch(ReaderAction.JumpToNextSearchResult) } + ) + when { + selectedHighlight != null -> { + SharedReaderHighlightSheet( + session = session, + highlight = selectedHighlight, + palette = highlightPalette, + onDismiss = { selectedHighlightId = null }, + onColorChange = { color -> + dispatch(ReaderAction.HighlightUpdated(selectedHighlight.id, color = color)) + }, + onSaveNote = { note -> + dispatch(ReaderAction.HighlightUpdated(selectedHighlight.id, note = note)) + }, + onDelete = { + dispatch(ReaderAction.HighlightDeleted(selectedHighlight.id)) + selectedHighlightId = null + }, + onCopy = { onCopyText(selectedHighlight.text) }, + onSearch = { onExternalLookup(ReaderExternalLookupAction.SEARCH, selectedHighlight.text) } + ) + } + readerExtrasState.aiResult.hasContent -> { + SharedReaderAiResultSheet( + result = readerExtrasState.aiResult, + onDismiss = onAiResultDismiss + ) + } + } + } +} + +@Composable +private fun SharedReaderSearchTopBar( + session: ReaderSessionState, + onReaderAction: (ReaderAction) -> Unit +) { + val focusRequester = remember(session.reader.book.id) { FocusRequester() } + + LaunchedEffect(session.isSearchActive) { + if (session.isSearchActive) { + delay(80) + runCatching { focusRequester.requestFocus() } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + IconButton( + onClick = { onReaderAction(ReaderAction.SearchClosed) }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + SharedStableOutlinedTextField( + value = session.searchQuery, + onValueChange = { onReaderAction(ReaderAction.SearchChanged(it)) }, + placeholder = { Text("Search in book") }, + singleLine = true, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + trailingIcon = if (session.searchQuery.isNotEmpty()) { + { + IconButton(onClick = { onReaderAction(ReaderAction.SearchChanged("")) }) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + } else { + null + }, + selectionKey = session.reader.book.id + ) + IconButton( + onClick = { onReaderAction(ReaderAction.SearchResultsPanelToggled) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + if (session.showSearchResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (session.showSearchResultsPanel) "Hide search results" else "Show search results" + ) + } + } + } +} + +@Composable +private fun BoxScope.SharedReaderSearchOverlay( + session: ReaderSessionState, + onResultClick: (Int) -> Unit, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit +) { + AnimatedVisibility( + visible = session.isSearchActive && session.showSearchResultsPanel, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.fillMaxSize().zIndex(30f) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + when { + session.searchQuery.isBlank() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Type to search this book", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + session.searchResults.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + else -> { + Column(Modifier.fillMaxSize()) { + Text( + "${session.searchResults.size} matches", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + HorizontalDivider() + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed( + items = session.searchResults, + key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } + ) { index, result -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, + color = if (index == session.activeSearchResultIndex) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Page ${result.pageIndex + 1} - ${result.chapterTitle}", + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + result.preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + HorizontalDivider() + } + } + } + } + } + } + } + + AnimatedVisibility( + visible = session.isSearchActive && !session.showSearchResultsPanel && session.searchResults.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 18.dp, bottom = 18.dp) + .zIndex(31f) + ) { + SharedReaderSearchNavigationPill( + session = session, + onShowResults = onShowResults, + onPrevious = onPrevious, + onNext = onNext + ) + } +} + +@Composable +private fun SharedReaderSearchNavigationPill( + session: ReaderSessionState, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit +) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = onPrevious, + enabled = session.canGoToPreviousSearchResult, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result") + } + Text( + text = if (session.activeSearchResultIndex in session.searchResults.indices) { + "${session.activeSearchResultIndex + 1}/${session.searchResults.size}" + } else { + "${session.searchResults.size} matches" + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) + ) + IconButton( + onClick = onNext, + enabled = session.canGoToNextSearchResult, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result") + } + } + } +} + +@Composable +private fun SharedReaderHighlightSheet( + session: ReaderSessionState, + highlight: UserHighlight, + palette: ReaderHighlightPalette, + onDismiss: () -> Unit, + onColorChange: (HighlightColor) -> Unit, + onSaveNote: (String) -> Unit, + onDelete: () -> Unit, + onCopy: () -> Unit, + onSearch: () -> Unit +) { + val locator = highlight.locator.withFallbacks( + chapterIndex = highlight.chapterIndex, + cfi = highlight.cfi, + textQuote = highlight.text + ) + val chapterTitle = session.reader.book.chapters + .getOrNull(locator.chapterIndex ?: highlight.chapterIndex) + ?.title + ?: "Chapter ${(locator.chapterIndex ?: highlight.chapterIndex) + 1}" + var noteText by remember(highlight.id, highlight.note) { mutableStateOf(highlight.note.orEmpty()) } + + SharedReaderBottomSheet( + title = "Highlight", + onDismiss = onDismiss + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + palette.sanitized().colors.forEach { color -> + Surface( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(30.dp) + .clickable { onColorChange(color) }, + color = color.color, + shape = RoundedCornerShape(15.dp), + border = BorderStroke( + width = if (highlight.color == color) 3.dp else 1.dp, + color = if (highlight.color == color) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.28f) + } + ), + content = {} + ) + } + } + Surface( + color = highlight.color.color.copy(alpha = 0.10f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, highlight.color.color.copy(alpha = 0.30f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.heightIn(min = 76.dp)) { + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .background(highlight.color.color) + ) + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - ReaderHtmlDocumentBuilder.pageDocument( - book = readerState.book, - page = page, - settings = settings, - searchQuery = session.searchQuery, - searchOptions = session.searchOptions, - highlights = session.highlights, - highlightPalette = highlightPalette, - navigationLocator = navigationLocator, - readerAiFeaturesEnabled = byokSettings.areReaderAiFeaturesAvailable, - cloudTtsEnabled = byokSettings.isCloudTtsAvailable, - textureDataUri = settings.textureId?.let(readerTextureDataUri) + Text( + chapterTitle, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "\"${highlight.text}\"", + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis ) } } - readerContent( - html, - background, - ReaderContentNavigationTarget( - locator = navigationLocator, - requestId = session.navigationRequestId, - readingMode = settings.readingMode, - autoScroll = readerExtrasState.autoScroll.sanitized(), - ttsLocator = activeTtsLocator, - ttsRequestId = ttsRequestId - ), - if (settings.readingMode == ReaderReadingMode.VERTICAL) session.highlights else emptyList(), - { pageIndex, locator -> dispatch(ReaderAction.VisiblePageChanged(pageIndex, locator)) } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + SharedReaderBottomSheetToolButton(Icons.Default.ContentCopy, "Copy") { + onCopy() + onDismiss() + } + SharedReaderBottomSheetToolButton(Icons.Default.Search, "Search") { + onSearch() + onDismiss() + } + } + SharedStableOutlinedTextField( + value = noteText, + onValueChange = { noteText = it }, + label = { Text("Note") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = highlight.id + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDelete) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + TextButton(onClick = { + onSaveNote(noteText) + onDismiss() + }) { + Text("Save note") + } + } + } +} + +@Composable +private fun SharedReaderBottomSheetToolButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp) + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun SharedReaderAiResultSheet( + result: ReaderAiResultState, + onDismiss: () -> Unit +) { + SharedReaderBottomSheet( + title = result.title ?: "AI", + onDismiss = onDismiss + ) { + val errorMessage = result.errorMessage + when { + result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) + errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) + else -> SharedMarkdownText(result.text) + } + } +} + +@Composable +private fun SharedReaderBottomSheet( + title: String, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + SharedReaderModalLayer(onDismiss = onDismiss) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .zIndex(40f) + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Transparent) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) ) + val sheetHorizontalPadding = 24.dp + val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) + .width(sharedReaderPopupWidth(sheetAvailableWidth)) + .heightIn(max = 560.dp), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(42.dp) + .height(4.dp) + .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + content() + } + } + } } } } @@ -495,12 +1096,16 @@ private fun SharedReaderQuickActions( onAutoScrollChange: (ReaderAutoScrollState) -> Unit, session: ReaderSessionState, extrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings + aiByokSettings: ReaderAiByokSettings, + cloudTtsControlsAvailable: Boolean, + externalLookupAvailable: Boolean ) { val tools = readerWorkspaceQuickActionTools( toolbarPreferences = toolbarPreferences, bottom = bottom, - aiAvailable = aiByokSettings.areReaderAiFeaturesAvailable + aiAvailable = aiByokSettings.areReaderAiFeaturesAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, + externalLookupAvailable = externalLookupAvailable ) if (tools.isEmpty()) return @@ -551,10 +1156,12 @@ private fun SharedReaderQuickActions( } ReaderTool.TTS_CONTROLS -> IconButton( - enabled = extrasState.cloudTts.isAvailable || + enabled = cloudTtsControlsAvailable && ( + extrasState.cloudTts.isAvailable || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || - extrasState.cloudTts.isPaused, + extrasState.cloudTts.isPaused + ), onClick = { if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || extrasState.cloudTts.isPaused) { onCloudTtsStop() @@ -566,7 +1173,7 @@ private fun SharedReaderQuickActions( } } ) { - Icon(Icons.Default.VolumeUp, contentDescription = if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || extrasState.cloudTts.isPaused) "Stop read aloud" else "Read aloud") + Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || extrasState.cloudTts.isPaused) "Stop read aloud" else "Read aloud") } ReaderTool.AUTO_SCROLL -> IconButton( @@ -588,29 +1195,34 @@ private fun SharedReaderQuickActions( private fun SharedReaderControlPanel( session: ReaderSessionState, toolbarPreferences: ReaderToolbarPreferences, - onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, onPickCustomFont: (() -> String?)?, customFonts: List, extrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + cloudTtsControlsAvailable: Boolean, onAiAction: (ReaderAiFeature, String) -> Unit, onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, - onCloudTtsPauseResume: () -> Unit, onCloudTtsStop: () -> Unit, - onCloudTtsClearCache: () -> Unit, onAutoScrollChange: (ReaderAutoScrollState) -> Unit, ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + highlightPalette: ReaderHighlightPalette, + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, readerCustomTextureIds: List, onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, onReaderAction: (ReaderAction) -> Unit ) { - val sections = toolbarPreferences.availableReaderControlSections() + val sections = toolbarPreferences.availableReaderControlSections(session) if (sections.isEmpty()) return - var selectedSection by remember { mutableStateOf(sections.first()) } - val activeSection = selectedSection.takeIf { it in sections } ?: sections.first() + val defaultSection = sections.first() + var selectedSection by remember(sections) { mutableStateOf(defaultSection) } + LaunchedEffect(sections) { + if (selectedSection !in sections) { + selectedSection = defaultSection + } + } + val activeSection = selectedSection.takeIf { it in sections } ?: defaultSection Surface( modifier = Modifier @@ -645,6 +1257,11 @@ private fun SharedReaderControlPanel( } item { when (activeSection) { + ReaderControlSection.PAGE -> SharedReaderPageControls( + session = session, + onReaderAction = onReaderAction + ) + ReaderControlSection.FORMAT -> SharedReaderFormatControls( settings = session.reader.settings, toolbarPreferences = toolbarPreferences, @@ -657,35 +1274,26 @@ private fun SharedReaderControlPanel( settings = session.reader.settings, customTextureIds = readerCustomTextureIds, onImportTexture = onImportReaderTexture, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, onSettingsChange = { onReaderAction(ReaderAction.SettingsChanged(it)) } ) - ReaderControlSection.VISUAL -> SharedReaderVisualOptionsControls( - settings = session.reader.settings, - onReaderAction = onReaderAction - ) - ReaderControlSection.EXTRAS -> SharedReaderExtrasControls( session = session, extrasState = extrasState, aiByokSettings = aiByokSettings, toolbarPreferences = toolbarPreferences, - onExternalLookup = onExternalLookup, + cloudTtsControlsAvailable = cloudTtsControlsAvailable, onAiAction = onAiAction, onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, onAutoScrollChange = onAutoScrollChange, ttsReplacementPreferences = ttsReplacementPreferences, ttsReplacementBookId = ttsReplacementBookId, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange ) - ReaderControlSection.TOOLBAR -> SharedReaderToolbarControls( - toolbarPreferences = toolbarPreferences, - onToolbarPreferencesChange = onToolbarPreferencesChange - ) } } } @@ -693,20 +1301,20 @@ private fun SharedReaderControlPanel( } private enum class ReaderControlSection(val title: String) { + PAGE("Page"), FORMAT("Format"), THEME("Theme"), - VISUAL("Visual"), - EXTRAS("Extras"), - TOOLBAR("Toolbar") + EXTRAS("Extras") } -private fun ReaderToolbarPreferences.availableReaderControlSections(): List { +private fun ReaderToolbarPreferences.availableReaderControlSections(session: ReaderSessionState): List { return buildList { + if (session.shouldShowJumpHistory) { + add(ReaderControlSection.PAGE) + } if (isVisible(ReaderTool.FORMAT) || isVisible(ReaderTool.READING_MODE)) add(ReaderControlSection.FORMAT) if (isVisible(ReaderTool.THEME)) add(ReaderControlSection.THEME) - if (isVisible(ReaderTool.VISUAL_OPTIONS)) add(ReaderControlSection.VISUAL) if ( - isVisible(ReaderTool.DICTIONARY) || isVisible(ReaderTool.AI_FEATURES) || isVisible(ReaderTool.TTS_CONTROLS) || isVisible(ReaderTool.TTS_SETTINGS) || @@ -715,12 +1323,29 @@ private fun ReaderToolbarPreferences.availableReaderControlSections(): List Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (session.shouldShowJumpHistory) { + Text("Jump history", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + SharedReaderJumpHistoryBar( + session = session, + onBack = { onReaderAction(ReaderAction.JumpBack) }, + onForward = { onReaderAction(ReaderAction.JumpForward) }, + onClear = { onReaderAction(ReaderAction.JumpHistoryCleared) } + ) + } + } +} + +@Composable +fun SharedReaderFormatControls( settings: ReaderSettings, toolbarPreferences: ReaderToolbarPreferences, onPickCustomFont: (() -> String?)?, @@ -746,6 +1371,28 @@ private fun SharedReaderFormatControls( label = { Text("Vertical") } ) } + if (settings.readingMode == ReaderReadingMode.PAGINATED) { + SharedReaderChoiceRow { + FilterChip( + selected = settings.pageSpreadMode == ReaderPageSpreadMode.SINGLE, + onClick = { + onReaderAction( + ReaderAction.SettingsChanged(settings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE)) + ) + }, + label = { Text("Single page") } + ) + FilterChip( + selected = settings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE, + onClick = { + onReaderAction( + ReaderAction.SettingsChanged(settings.copy(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)) + ) + }, + label = { Text("Two pages") } + ) + } + } } } @@ -870,10 +1517,12 @@ private fun SharedReaderFormatControls( label = "Font size", value = settings.fontSize.toFloat(), onValueChange = { value -> - onReaderAction(ReaderAction.SettingsChanged(settings.copy(fontSize = value.toInt()))) + onReaderAction(ReaderAction.SettingsChanged(settings.copy(fontSize = value.roundToInt()))) }, valueRange = 14f..30f, - valueLabel = settings.fontSize.toString() + valueLabel = settings.fontSize.toString(), + stepSize = 1f, + formatValue = { it.roundToInt().toString() } ) SharedReaderSettingSlider( label = "Line height", @@ -882,7 +1531,8 @@ private fun SharedReaderFormatControls( onReaderAction(ReaderAction.SettingsChanged(settings.copy(lineSpacing = value))) }, valueRange = 1.1f..2.1f, - valueLabel = "${settings.lineSpacing.formatTwoDecimals()}x" + valueLabel = "${settings.lineSpacing.formatTwoDecimals()}x", + formatValue = { "${it.formatTwoDecimals()}x" } ) SharedReaderSettingSlider( label = "Paragraph gap", @@ -891,7 +1541,8 @@ private fun SharedReaderFormatControls( onReaderAction(ReaderAction.SettingsChanged(settings.copy(paragraphSpacing = value))) }, valueRange = 0.5f..2.5f, - valueLabel = "${settings.paragraphSpacing.formatTwoDecimals()}x" + valueLabel = "${settings.paragraphSpacing.formatTwoDecimals()}x", + formatValue = { "${it.formatTwoDecimals()}x" } ) SharedReaderSettingSlider( label = "Image size", @@ -900,13 +1551,14 @@ private fun SharedReaderFormatControls( onReaderAction(ReaderAction.SettingsChanged(settings.copy(imageScale = value))) }, valueRange = 0.5f..2.0f, - valueLabel = "${settings.imageScale.formatTwoDecimals()}x" + valueLabel = "${settings.imageScale.formatTwoDecimals()}x", + formatValue = { "${it.formatTwoDecimals()}x" } ) SharedReaderSettingSlider( label = "Horizontal margin", value = settings.resolvedHorizontalMargin.toFloat(), onValueChange = { value -> - val nextHorizontal = value.toInt() + val nextHorizontal = value.roundToInt() val nextMargin = maxOf(nextHorizontal, settings.resolvedVerticalMargin) onReaderAction( ReaderAction.SettingsChanged( @@ -915,13 +1567,15 @@ private fun SharedReaderFormatControls( ) }, valueRange = 0f..160f, - valueLabel = settings.resolvedHorizontalMargin.toString() + valueLabel = settings.resolvedHorizontalMargin.toString(), + stepSize = 4f, + formatValue = { it.roundToInt().toString() } ) SharedReaderSettingSlider( label = "Vertical margin", value = settings.resolvedVerticalMargin.toFloat(), onValueChange = { value -> - val nextVertical = value.toInt() + val nextVertical = value.roundToInt() val nextMargin = maxOf(settings.resolvedHorizontalMargin, nextVertical) onReaderAction( ReaderAction.SettingsChanged( @@ -930,16 +1584,20 @@ private fun SharedReaderFormatControls( ) }, valueRange = 0f..160f, - valueLabel = settings.resolvedVerticalMargin.toString() + valueLabel = settings.resolvedVerticalMargin.toString(), + stepSize = 4f, + formatValue = { it.roundToInt().toString() } ) SharedReaderSettingSlider( label = "Page width", value = settings.pageWidth.toFloat(), onValueChange = { value -> - onReaderAction(ReaderAction.SettingsChanged(settings.copy(pageWidth = value.toInt()))) + onReaderAction(ReaderAction.SettingsChanged(settings.copy(pageWidth = value.roundToInt()))) }, valueRange = 520f..1100f, - valueLabel = settings.pageWidth.toString() + valueLabel = settings.pageWidth.toString(), + stepSize = 20f, + formatValue = { it.roundToInt().toString() } ) } } @@ -952,9 +1610,12 @@ fun SharedReaderThemeControls( builtInThemes: List = BuiltInReaderThemes, customTextureIds: List = emptyList(), onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + highlightPalette: ReaderHighlightPalette? = null, + onHighlightPaletteChange: ((ReaderHighlightPalette) -> Unit)? = null, onSettingsChange: (ReaderSettings) -> Unit ) { var textured by remember(settings.themeId, settings.textureId) { mutableStateOf(settings.textureId != null) } + var editingColorTarget by remember { mutableStateOf(null) } val activeThemes = builtInThemes.filter { (it.textureId != null) == textured } val visibleCustomTextureIds = remember(customTextureIds, settings.textureId) { buildList { @@ -996,6 +1657,49 @@ fun SharedReaderThemeControls( } } + SharedReaderPanelSection("Custom colors") { + val backgroundColor = settings.readerBackgroundColor(builtInThemes) + val textColor = settings.readerTextColor(builtInThemes) + Surface( + modifier = Modifier.fillMaxWidth().height(76.dp), + color = backgroundColor, + contentColor = textColor, + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp), + verticalArrangement = Arrangement.Center + ) { + Text("Custom theme preview", fontWeight = FontWeight.SemiBold, maxLines = 1) + Text("Page and text colors", style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + SharedReaderThemeColorButton( + label = "Page", + color = backgroundColor, + onClick = { editingColorTarget = ReaderThemeColorTarget.BACKGROUND }, + modifier = Modifier.weight(1f) + ) + SharedReaderThemeColorButton( + label = "Text", + color = textColor, + onClick = { editingColorTarget = ReaderThemeColorTarget.TEXT }, + modifier = Modifier.weight(1f) + ) + } + } + + if (highlightPalette != null && onHighlightPaletteChange != null) { + SharedReaderPanelSection("Highlight palette") { + SharedHighlightPaletteEditor( + palette = highlightPalette, + onPaletteChange = onHighlightPaletteChange + ) + } + } + if (textured) { SharedReaderPanelSection("Texture") { SharedReaderChoiceRow { @@ -1037,16 +1741,109 @@ fun SharedReaderThemeControls( onSettingsChange(settings.copy(textureAlpha = value)) }, valueRange = 0f..1f, - valueLabel = "${(settings.textureAlpha.coerceIn(0f, 1f) * 100).roundToInt()}%" + valueLabel = "${(settings.textureAlpha.coerceIn(0f, 1f) * 100).roundToInt()}%", + stepSize = 0.01f, + formatValue = { "${(it.coerceIn(0f, 1f) * 100).roundToInt()}%" } ) } } } } + + editingColorTarget?.let { target -> + val backgroundColor = settings.readerBackgroundColor(builtInThemes) + val textColor = settings.readerTextColor(builtInThemes) + val initialColor = when (target) { + ReaderThemeColorTarget.BACKGROUND -> backgroundColor + ReaderThemeColorTarget.TEXT -> textColor + } + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = target.title, + onDismiss = { editingColorTarget = null }, + onSave = { color -> + val nextBackground = if (target == ReaderThemeColorTarget.BACKGROUND) color else backgroundColor + val nextText = if (target == ReaderThemeColorTarget.TEXT) color else textColor + onSettingsChange( + settings.copy( + themeId = ReaderCustomThemeId, + darkMode = nextBackground.luminance() < 0.45f, + backgroundColorArgb = nextBackground.toArgb().toLong(), + textColorArgb = nextText.toArgb().toLong() + ) + ) + editingColorTarget = null + } + ) { color -> + val previewBackground = if (target == ReaderThemeColorTarget.BACKGROUND) color else backgroundColor + val previewText = if (target == ReaderThemeColorTarget.TEXT) color else textColor + Surface( + modifier = Modifier.fillMaxWidth().height(64.dp), + shape = RoundedCornerShape(10.dp), + color = previewBackground, + contentColor = previewText, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Live preview", fontWeight = FontWeight.Bold) + Text("Page and text colors", style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +private const val ReaderCustomThemeId = "custom_reader" + +private enum class ReaderThemeColorTarget(val title: String) { + BACKGROUND("Page color"), + TEXT("Text color") } @Composable -private fun SharedReaderVisualOptionsControls( +private fun SharedReaderThemeColorButton( + label: String, + color: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + onClick = onClick, + modifier = modifier.height(54.dp), + shape = RoundedCornerShape(10.dp), + color = color, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) { + Text( + label, + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun ReaderSettings.readerBackgroundColor(themes: List): Color { + return backgroundColorArgb?.toComposeColor() + ?: themes.firstOrNull { it.id == themeId }?.backgroundColor?.takeIf { it.isSpecified } + ?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) +} + +private fun ReaderSettings.readerTextColor(themes: List): Color { + return textColorArgb?.toComposeColor() + ?: themes.firstOrNull { it.id == themeId }?.textColor?.takeIf { it.isSpecified } + ?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) +} + +@Composable +fun SharedReaderVisualOptionsControls( settings: ReaderSettings, onReaderAction: (ReaderAction) -> Unit ) { @@ -1105,7 +1902,9 @@ private fun SharedReaderVisualOptionsControls( onReaderAction(ReaderAction.SettingsChanged(settings.copy(chapterTurnDragMultiplier = value))) }, valueRange = 0.5f..2.0f, - valueLabel = "${settings.chapterTurnDragMultiplier.formatTwoDecimals()}x" + valueLabel = "${settings.chapterTurnDragMultiplier.formatTwoDecimals()}x", + stepSize = 0.05f, + formatValue = { "${it.formatTwoDecimals()}x" } ) } } @@ -1117,12 +1916,10 @@ private fun SharedReaderExtrasControls( extrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, toolbarPreferences: ReaderToolbarPreferences, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + cloudTtsControlsAvailable: Boolean, onAiAction: (ReaderAiFeature, String) -> Unit, onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, - onCloudTtsPauseResume: () -> Unit, onCloudTtsStop: () -> Unit, - onCloudTtsClearCache: () -> Unit, onAutoScrollChange: (ReaderAutoScrollState) -> Unit, ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String, @@ -1134,19 +1931,6 @@ private fun SharedReaderExtrasControls( val recapText = ReaderContextExtractor.textBeforeCurrentLocation(session) Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { - SharedReaderPanelSection("External Apps") { - SharedReaderChoiceRow { - ReaderExternalLookupAction.entries.forEach { action -> - FilterChip( - selected = false, - enabled = currentPageText.isNotBlank(), - onClick = { onExternalLookup(action, currentPageText) }, - label = { Text(action.title) } - ) - } - } - } - SharedReaderPanelSection("Auto Scroll") { val autoScroll = extrasState.autoScroll.sanitized() Row( @@ -1165,104 +1949,53 @@ private fun SharedReaderExtrasControls( value = autoScroll.speed, onValueChange = { speed -> onAutoScrollChange(autoScroll.copy(speed = speed).sanitized()) }, valueRange = 12f..160f, - valueLabel = "${autoScroll.speed.roundToInt()}" + valueLabel = "${autoScroll.speed.roundToInt()}", + stepSize = 1f, + formatValue = { it.roundToInt().toString() } ) } - SharedReaderPanelSection("Cloud TTS") { - val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( + if (cloudTtsControlsAvailable) { + SharedReaderPanelSection("Cloud TTS") { + val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> "Preparing audio" + extrasState.cloudTts.isPaused -> "Paused" + extrasState.cloudTts.isPlaying -> "Reading" + settings.isCloudTtsAvailable -> "Ready" + else -> "Needs Gemini key" + }, + fontWeight = FontWeight.SemiBold + ) + val errorMessage = extrasState.cloudTts.errorMessage?.takeIf { it.isNotBlank() } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } when { - extrasState.cloudTts.isLoading -> "Preparing audio" - extrasState.cloudTts.isPaused -> "Paused" - extrasState.cloudTts.isPlaying -> "Reading" - settings.isCloudTtsAvailable -> "Ready" - else -> "Needs Gemini key" - }, - fontWeight = FontWeight.SemiBold - ) - val errorMessage = extrasState.cloudTts.errorMessage?.takeIf { it.isNotBlank() } - val statusMessage = extrasState.cloudTts.progress.currentPositionLabel - ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } - when { - errorMessage != null -> Text(errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - statusMessage != null -> Text(statusMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - TextButton( - enabled = settings.isCloudTtsAvailable || ttsBusy, - onClick = { - if (ttsBusy) { - onCloudTtsStop() - } else { - onCloudTtsStart( - ReaderTtsReadScope.BOOK, - ReaderTtsPlanner.chunksFromCurrentLocation(session) - ) + errorMessage != null -> Text(errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + statusMessage != null -> Text(statusMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - ) { - Text(if (ttsBusy) "Stop" else "Read") - } - } - if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { - SharedReaderChoiceRow { - TextButton(onClick = onCloudTtsPauseResume) { - Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") - } - } - } - SharedReaderChoiceRow { - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && currentPageText.isNotBlank(), - onClick = { - onCloudTtsStart( - ReaderTtsReadScope.PAGE, - ReaderTtsPlanner.chunksForCurrentPage(session) - ) - } - ) { - Text("Page") - } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && currentChapterText.isNotBlank(), - onClick = { - onCloudTtsStart( - ReaderTtsReadScope.CHAPTER, - ReaderTtsPlanner.chunksForCurrentChapter(session) - ) - } - ) { - Text("Chapter") - } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && currentPageText.isNotBlank(), - onClick = { - onCloudTtsStart( - ReaderTtsReadScope.BOOK, - ReaderTtsPlanner.chunksFromCurrentLocation(session) - ) - } - ) { - Text("From here") - } - } - val cacheSummary = extrasState.cloudTts.cacheSummary - if (cacheSummary.hasCachedAudio) { - Text( - "Cache: ${cacheSummary.currentVoiceLabel}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (cacheSummary.hasCurrentVoiceCachedAudio) { - TextButton(onClick = onCloudTtsClearCache) { - Text("Clear voice cache") + TextButton( + enabled = settings.isCloudTtsAvailable || ttsBusy, + onClick = { + if (ttsBusy) { + onCloudTtsStop() + } else { + onCloudTtsStart( + ReaderTtsReadScope.BOOK, + ReaderTtsPlanner.chunksFromCurrentLocation(session) + ) + } + } + ) { + Text(if (ttsBusy) "Stop" else "Read") } } } @@ -1302,25 +2035,10 @@ private fun SharedReaderExtrasControls( Text("Recap") } } - if (extrasState.aiResult.hasContent) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(extrasState.aiResult.title ?: "AI", fontWeight = FontWeight.SemiBold) - when { - extrasState.aiResult.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) - extrasState.aiResult.errorMessage != null -> Text(extrasState.aiResult.errorMessage, color = MaterialTheme.colorScheme.error) - else -> SharedMarkdownText(extrasState.aiResult.text) - } - } - } - } } } } + } private enum class SharedTtsReplacementScope { @@ -1332,11 +2050,13 @@ private enum class SharedTtsReplacementScope { fun SharedReaderTtsReplacementControls( preferences: ReaderTtsReplacementPreferences, bookId: String, - onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit + onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + allowBookScope: Boolean = true ) { - var selectedScope by remember(bookId) { mutableStateOf(SharedTtsReplacementScope.GLOBAL) } - var editingRuleId by remember(bookId, selectedScope) { mutableStateOf(null) } - var isAddingRule by remember(bookId, selectedScope) { mutableStateOf(false) } + var selectedScope by remember(bookId, allowBookScope) { mutableStateOf(SharedTtsReplacementScope.GLOBAL) } + val effectiveScope = if (allowBookScope) selectedScope else SharedTtsReplacementScope.GLOBAL + var editingRuleId by remember(bookId, effectiveScope) { mutableStateOf(null) } + var isAddingRule by remember(bookId, effectiveScope) { mutableStateOf(false) } val bookSettings = preferences.settingsForBook(bookId) val bookRules = preferences.rulesForBook(bookId) @@ -1360,28 +2080,30 @@ fun SharedReaderTtsReplacementControls( ) } - SharedReaderChoiceRow { - FilterChip( - selected = selectedScope == SharedTtsReplacementScope.GLOBAL, - onClick = { - selectedScope = SharedTtsReplacementScope.GLOBAL - editingRuleId = null - isAddingRule = false - }, - label = { Text("Global") } - ) - FilterChip( - selected = selectedScope == SharedTtsReplacementScope.BOOK, - onClick = { - selectedScope = SharedTtsReplacementScope.BOOK - editingRuleId = null - isAddingRule = false - }, - label = { Text("This book") } - ) + if (allowBookScope) { + SharedReaderChoiceRow { + FilterChip( + selected = selectedScope == SharedTtsReplacementScope.GLOBAL, + onClick = { + selectedScope = SharedTtsReplacementScope.GLOBAL + editingRuleId = null + isAddingRule = false + }, + label = { Text("Global") } + ) + FilterChip( + selected = selectedScope == SharedTtsReplacementScope.BOOK, + onClick = { + selectedScope = SharedTtsReplacementScope.BOOK + editingRuleId = null + isAddingRule = false + }, + label = { Text("This book") } + ) + } } - when (selectedScope) { + when (effectiveScope) { SharedTtsReplacementScope.GLOBAL -> { SharedTtsReplacementSuggestionsRow { suggestion -> onPreferencesChange( @@ -1627,7 +2349,7 @@ private fun SharedTtsReplacementRuleEditor( ) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(if (seedRule == null) "New rule" else "Edit rule", fontWeight = FontWeight.SemiBold) - OutlinedTextField( + SharedStableOutlinedTextField( value = from, onValueChange = { from = it }, label = { Text("Replace") }, @@ -1637,7 +2359,7 @@ private fun SharedTtsReplacementRuleEditor( if (!validation.isValid && validation.message != null) { Text(validation.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } - OutlinedTextField( + SharedStableOutlinedTextField( value = to, onValueChange = { to = it }, label = { Text("Speak as") }, @@ -1649,7 +2371,7 @@ private fun SharedTtsReplacementRuleEditor( FilterChip(selected = wholeWord, onClick = { wholeWord = !wholeWord }, label = { Text("Whole word") }) FilterChip(selected = matchCase, onClick = { matchCase = !matchCase }, label = { Text("Match case") }) } - OutlinedTextField( + SharedStableOutlinedTextField( value = previewText, onValueChange = { previewText = it }, label = { Text("Preview") }, @@ -1733,7 +2455,7 @@ private fun newSharedReplacementRuleId( } @Composable -private fun SharedReaderToolbarControls( +fun SharedReaderToolbarControls( toolbarPreferences: ReaderToolbarPreferences, onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit ) { @@ -1862,24 +2584,138 @@ private fun SharedReaderSettingSlider( value: Float, onValueChange: (Float) -> Unit, valueRange: ClosedFloatingPointRange, - valueLabel: String + valueLabel: String, + stepSize: Float = 0.05f, + formatValue: ((Float) -> String)? = null, + debounceMillis: Long = 320L ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(label, style = MaterialTheme.typography.bodyMedium) - Text(valueLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + val rangeStart = valueRange.start + val rangeEnd = valueRange.endInclusive + fun snap(raw: Float): Float { + val clamped = raw.coerceIn(rangeStart, rangeEnd) + if (stepSize <= 0f) return clamped + val steps = ((clamped - rangeStart) / stepSize).roundToInt() + return (rangeStart + steps * stepSize).coerceIn(rangeStart, rangeEnd) + } + + var draftValue by remember(label, rangeStart, rangeEnd) { mutableFloatStateOf(snap(value)) } + var pendingCommit by remember(label, rangeStart, rangeEnd) { mutableStateOf(null) } + var isDragging by remember(label, rangeStart, rangeEnd) { mutableStateOf(false) } + var lastCommitted by remember(label, rangeStart, rangeEnd) { mutableFloatStateOf(snap(value)) } + val normalizedExternalValue = snap(value) + + LaunchedEffect(normalizedExternalValue) { + if (pendingCommit == null) { + draftValue = normalizedExternalValue + lastCommitted = normalizedExternalValue + } + } + + fun commit(next: Float) { + val snapped = snap(next) + draftValue = snapped + if (snapped != lastCommitted) { + lastCommitted = snapped + onValueChange(snapped) + } + } + + LaunchedEffect(pendingCommit, isDragging) { + if (isDragging) return@LaunchedEffect + val pending = pendingCommit ?: return@LaunchedEffect + delay(debounceMillis) + commit(pending) + if (pendingCommit == pending) { + pendingCommit = null + } + } + + fun updateDraft(next: Float) { + val snapped = snap(next) + draftValue = snapped + pendingCommit = snapped + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + formatValue?.invoke(draftValue) ?: valueLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton( + onClick = { + isDragging = false + val next = snap(draftValue - stepSize) + pendingCommit = null + commit(next) + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Remove, + contentDescription = "Decrease $label", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ReaderMinimalSlider( + value = draftValue, + onValueChange = ::updateDraft, + onValueChangeStarted = { isDragging = true }, + onValueChangeFinished = { + isDragging = false + pendingCommit?.let { commit(it) } + pendingCommit = null + }, + valueRange = valueRange, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + thumbColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + isDragging = false + val next = snap(draftValue + stepSize) + pendingCommit = null + commit(next) + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = "Increase $label", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } } - Slider( - value = value.coerceIn(valueRange.start, valueRange.endInclusive), - onValueChange = onValueChange, - valueRange = valueRange - ) } } @Composable private fun SharedReaderThemeChoice( - theme: com.aryan.reader.shared.ReaderTheme, + theme: ReaderTheme, selected: Boolean, onSelected: () -> Unit, modifier: Modifier = Modifier @@ -1929,367 +2765,863 @@ private fun SharedReaderThemeChoice( } } -@Composable -private fun SharedReaderPageSlider( - session: ReaderSessionState, - onPageNumberChange: (Int) -> Unit +private const val ReaderGapChromeLogTag = "EpistemeReaderGap" + +private fun logReaderGapChrome( + layer: String, + bounds: Rect, + details: String = "" ) { - val readerState = session.reader - val totalPages = readerState.pages.size.coerceAtLeast(1) - val sliderMax = totalPages.coerceAtLeast(2) - val currentPageNumber = (readerState.currentPageIndex + 1).coerceIn(1, totalPages) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("$currentPageNumber / $totalPages") - Slider( - value = currentPageNumber.toFloat(), - onValueChange = { value -> onPageNumberChange(value.roundToInt().coerceIn(1, totalPages)) }, - valueRange = 1f..sliderMax.toFloat(), - steps = if (totalPages > 2) totalPages - 2 else 0, - enabled = totalPages > 1, - modifier = Modifier.weight(1f) - ) - Text( - readerState.currentPage?.chapterTitle.orEmpty(), - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(180.dp) - ) + logSharedReaderDiagnostic(ReaderGapChromeLogTag) { + buildString { + append("compose_reader layer=") + append(layer) + append(" x=") + append(bounds.left.roundToInt()) + append(" y=") + append(bounds.top.roundToInt()) + append(" w=") + append(bounds.width.roundToInt()) + append(" h=") + append(bounds.height.roundToInt()) + append(" bottom=") + append(bounds.bottom.roundToInt()) + if (details.isNotBlank()) { + append(' ') + append(details) + } + } } } +@Composable +private fun SharedReaderCompactNavigation( + session: ReaderSessionState, + showSlider: Boolean, + canGoPrevious: Boolean, + canGoNext: Boolean, + pageInfoText: String?, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageNumberChange: (Int) -> Unit, + contentColor: Color = MaterialTheme.colorScheme.onSurface +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .onGloballyPositioned { coordinates -> + logReaderGapChrome( + layer = "bottom_nav_row", + bounds = coordinates.boundsInWindow(), + details = "showSlider=$showSlider pageInfo=${pageInfoText != null} canPrev=$canGoPrevious canNext=$canGoNext" + ) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + enabled = canGoPrevious, + onClick = onPrevious, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Previous page", + tint = contentColor.copy(alpha = if (canGoPrevious) 0.78f else 0.32f), + modifier = Modifier.size(22.dp) + ) + } + if (showSlider) { + SharedReaderPageSlider( + session = session, + onPageNumberChange = onPageNumberChange, + contentColor = contentColor, + modifier = Modifier.weight(1f) + ) + } else { + Text( + pageInfoText.orEmpty(), + color = contentColor.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + IconButton( + enabled = canGoNext, + onClick = onNext, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next page", + tint = contentColor.copy(alpha = if (canGoNext) 0.78f else 0.32f), + modifier = Modifier.size(22.dp) + ) + } + } +} + +@Composable +private fun SharedReaderFullscreenNavigation( + session: ReaderSessionState, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageNumberChange: (Int) -> Unit, + onJumpBack: () -> Unit, + onJumpForward: () -> Unit, + onClearJumpHistory: () -> Unit, + backgroundColor: Color, + contentColor: Color, + modifier: Modifier = Modifier +) { + val readerState = session.reader + val totalPages = readerState.pages.size.coerceAtLeast(1) + val sliderSteps = ReaderSpreadLayout.sliderStepCount(totalPages, readerState.settings) + val sliderMax = sliderSteps.coerceAtLeast(2) + val currentSliderPosition = ReaderSpreadLayout.sliderPositionForPage( + pageIndex = readerState.currentPageIndex, + pageCount = totalPages, + settings = readerState.settings + ) + Surface( + modifier = modifier + .fillMaxWidth(), + color = backgroundColor, + contentColor = contentColor, + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!session.isSearchActive && session.shouldShowJumpHistory) { + SharedReaderJumpHistoryBar( + session = session, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + HorizontalDivider(color = contentColor.copy(alpha = 0.14f)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + enabled = readerState.canGoPrevious, + onClick = onPrevious + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Previous page", + tint = contentColor.copy(alpha = if (readerState.canGoPrevious) 0.78f else 0.32f), + modifier = Modifier.size(22.dp) + ) + } + ReaderMinimalSlider( + value = currentSliderPosition.toFloat(), + onValueChange = { value -> + onPageNumberChange( + ReaderSpreadLayout.pageNumberForSliderPosition( + position = value.roundToInt(), + pageCount = totalPages, + settings = readerState.settings + ) + ) + }, + valueRange = 1f..sliderMax.toFloat(), + enabled = sliderSteps > 1, + activeColor = contentColor.copy(alpha = 0.68f), + inactiveColor = contentColor.copy(alpha = 0.24f), + thumbColor = contentColor.copy(alpha = 0.92f), + modifier = Modifier.weight(1f) + ) + IconButton( + enabled = readerState.canGoNext, + onClick = onNext + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next page", + tint = contentColor.copy(alpha = if (readerState.canGoNext) 0.78f else 0.32f), + modifier = Modifier.size(22.dp) + ) + } + } + } + } +} + +@Composable +private fun SharedReaderPageSlider( + session: ReaderSessionState, + onPageNumberChange: (Int) -> Unit, + contentColor: Color, + modifier: Modifier = Modifier +) { + val readerState = session.reader + val totalPages = readerState.pages.size.coerceAtLeast(1) + val sliderSteps = ReaderSpreadLayout.sliderStepCount(totalPages, readerState.settings) + val sliderMax = sliderSteps.coerceAtLeast(2) + val currentSliderPosition = ReaderSpreadLayout.sliderPositionForPage( + pageIndex = readerState.currentPageIndex, + pageCount = totalPages, + settings = readerState.settings + ) + val pageRangeLabel = ReaderSpreadLayout.pageRangeLabel(readerState.currentPageIndex, totalPages, readerState.settings) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "$pageRangeLabel / $totalPages", + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.72f) + ) + ReaderMinimalSlider( + value = currentSliderPosition.toFloat(), + onValueChange = { value -> + onPageNumberChange( + ReaderSpreadLayout.pageNumberForSliderPosition( + position = value.roundToInt(), + pageCount = totalPages, + settings = readerState.settings + ) + ) + }, + valueRange = 1f..sliderMax.toFloat(), + enabled = sliderSteps > 1, + activeColor = contentColor.copy(alpha = 0.62f), + inactiveColor = contentColor.copy(alpha = 0.18f), + thumbColor = contentColor.copy(alpha = 0.86f), + modifier = Modifier.weight(1f) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SharedReaderSidebar( session: ReaderSessionState, - onSearchChange: (String) -> Unit, - onPreviousSearchResult: () -> Unit, - onNextSearchResult: () -> Unit, - onOpenSearch: () -> Unit, - onCloseSearch: () -> Unit, - onToggleSearchResultsPanel: () -> Unit, - onSearchOptionsChange: (ReaderSearchOptions) -> Unit, + readerEngine: ReaderEngine, + sections: List, onGoToChapter: (Int) -> Unit, + onGoToLocator: (ReaderLocator) -> Unit, onGoToBookmark: (ReaderBookmark) -> Unit, - onGoToSearchResult: (Int) -> Unit, - toolbarPreferences: ReaderToolbarPreferences, - highlightPalette: ReaderHighlightPalette, - onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, onGoToHighlight: (UserHighlight) -> Unit, + onEditHighlight: (UserHighlight) -> Unit, + highlightPalette: ReaderHighlightPalette, onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit, - onHighlightNoteChange: (UserHighlight, String) -> Unit, - onHighlightDelete: (UserHighlight) -> Unit + onDeleteHighlight: (UserHighlight) -> Unit ) { + val tabs = remember(sections) { + listOf( + ReaderWorkspaceLeftSection.CONTENTS, + ReaderWorkspaceLeftSection.NOTES, + ReaderWorkspaceLeftSection.BOOKMARKS + ).filter { it in sections } + } + var selectedSection by remember(tabs) { mutableStateOf(tabs.firstOrNull()) } + val selectedTabIndex = tabs.indexOf(selectedSection).takeIf { it >= 0 } ?: 0 + Surface( modifier = Modifier - .width(280.dp) + .width(300.dp) .fillMaxHeight(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp) ) { - LazyColumn( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (toolbarPreferences.isVisible(ReaderTool.TOC)) { - item { - Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - items(session.reader.book.chapters.indices.toList()) { index -> - val chapter = session.reader.book.chapters[index] - val selected = session.reader.currentPage?.chapterIndex == index - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToChapter(index) } - ) { - Text( - chapter.title, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis + Column(Modifier.fillMaxSize()) { + if (tabs.isNotEmpty()) { + ScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp + ) { + tabs.forEach { section -> + Tab( + selected = selectedSection == section, + onClick = { selectedSection = section }, + text = { + Text( + section.readerNavigationTabLabel(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } ) } } } - if (toolbarPreferences.isVisible(ReaderTool.BOOKMARK)) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - if (session.bookmarks.isEmpty()) { - item { - Text("No bookmarks yet", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - items(session.bookmarks, key = { it.id }) { bookmark -> - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToBookmark(bookmark) } - ) { - Column( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth() - ) { - Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - } - } - } - - if (toolbarPreferences.isVisible(ReaderTool.BOOKMARK)) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Highlights", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - if (session.highlights.isEmpty()) { - item { - Text("No highlights yet", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - items(session.highlights, key = { it.id }) { highlight -> - SharedHighlightListItem( - session = session, - highlight = highlight, - palette = highlightPalette, - onGoToHighlight = onGoToHighlight, - onColorChange = onHighlightColorChange, - onNoteChange = onHighlightNoteChange, - onDelete = onHighlightDelete - ) - } - } - item { - SharedHighlightPaletteEditor( - palette = highlightPalette, - onPaletteChange = onHighlightPaletteChange - ) - } - } - - if (toolbarPreferences.isVisible(ReaderTool.SEARCH)) { - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - TextButton(onClick = if (session.isSearchActive) onCloseSearch else onOpenSearch) { - Text(if (session.isSearchActive) "Close" else "Open") - } - } - Spacer(Modifier.height(8.dp)) - if (session.isSearchActive) { - OutlinedTextField( - value = session.searchQuery, - onValueChange = onSearchChange, - label = { Text("Find in book") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - FilterChip( - selected = session.searchOptions.matchCase, - onClick = { - onSearchOptionsChange(session.searchOptions.copy(matchCase = !session.searchOptions.matchCase)) - }, - label = { Text("Match case") } - ) - FilterChip( - selected = session.searchOptions.wholeWords, - onClick = { - onSearchOptionsChange(session.searchOptions.copy(wholeWords = !session.searchOptions.wholeWords)) - }, - label = { Text("Whole words") } - ) - if (session.searchQuery.isNotBlank()) { - TextButton(onClick = onToggleSearchResultsPanel) { - Text(if (session.showSearchResultsPanel) "Hide results" else "Show results") - } - } - } - } - if (session.isSearchActive && session.searchQuery.isNotBlank() && session.searchResults.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "${session.activeSearchResultIndex + 1} of ${session.searchResults.size}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton( - enabled = session.canGoToPreviousSearchResult, - onClick = onPreviousSearchResult - ) { - Text("Prev") - } - TextButton( - enabled = session.canGoToNextSearchResult, - onClick = onNextSearchResult - ) { - Text("Next") - } - } - } - } - if (session.isSearchActive && session.searchQuery.isNotBlank() && session.searchResults.isEmpty()) { - item { - Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else if (session.isSearchActive && session.showSearchResultsPanel) { - itemsIndexed( - session.searchResults, - key = { _, result -> "${result.pageIndex}_${result.matchIndex}_${result.chapterIndex}_${result.preview}" } - ) { index, result -> - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToSearchResult(index) } - ) { - Column(modifier = Modifier.padding(8.dp)) { - Text("Page ${result.pageIndex + 1} - ${result.chapterTitle}", fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) - } - } - } - } + when (selectedSection) { + ReaderWorkspaceLeftSection.CONTENTS -> SharedReaderTocTab( + session = session, + readerEngine = readerEngine, + onGoToLocator = onGoToLocator, + onGoToChapter = onGoToChapter + ) + ReaderWorkspaceLeftSection.NOTES -> SharedReaderAnnotationsTab( + session = session, + onGoToHighlight = onGoToHighlight, + onEditHighlight = onEditHighlight, + highlightPalette = highlightPalette, + onHighlightColorChange = onHighlightColorChange, + onDeleteHighlight = onDeleteHighlight + ) + ReaderWorkspaceLeftSection.BOOKMARKS -> SharedReaderBookmarksTab( + session = session, + onGoToBookmark = onGoToBookmark + ) + else -> SharedReaderEmptyNavigation("No navigation items") } } } } @Composable -private fun SharedHighlightListItem( +private fun SharedReaderTocTab( session: ReaderSessionState, - highlight: UserHighlight, - palette: ReaderHighlightPalette, - onGoToHighlight: (UserHighlight) -> Unit, - onColorChange: (UserHighlight, HighlightColor) -> Unit, - onNoteChange: (UserHighlight, String) -> Unit, - onDelete: (UserHighlight) -> Unit + readerEngine: ReaderEngine, + onGoToLocator: (ReaderLocator) -> Unit, + onGoToChapter: (Int) -> Unit ) { - val locator = highlight.locator.withFallbacks( - chapterIndex = highlight.chapterIndex, - cfi = highlight.cfi, - textQuote = highlight.text - ) - val chapterTitle = session.reader.book.chapters - .getOrNull(locator.chapterIndex ?: highlight.chapterIndex) - ?.title - ?: "Chapter ${(locator.chapterIndex ?: highlight.chapterIndex) + 1}" - val pageLabel = locator.pageIndex?.let { "Page ${it + 1}" } - val colors = palette.sanitized().colors - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onGoToHighlight(highlight) } - ) { - Column( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box( - modifier = Modifier - .width(12.dp) - .height(12.dp) - .background(highlight.color.color, RoundedCornerShape(2.dp)) - ) - Text( - listOfNotNull(chapterTitle, pageLabel).joinToString(" - "), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val chapters = session.reader.book.chapters + val tocEntries = remember(session.reader.book.tableOfContents, chapters) { + session.reader.book.tableOfContents.ifEmpty { + chapters.map { chapter -> + SharedEpubTocEntry( + label = chapter.title, + href = chapter.baseHref ?: chapter.id, + depth = 0 ) } - Text(highlight.text, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.horizontalScroll(rememberScrollState()) + } + } + if (tocEntries.isEmpty()) { + SharedReaderEmptyNavigation("No table of contents") + return + } + + val allParentIndices = remember(tocEntries) { + tocEntries.indices.filter { index -> + val next = tocEntries.getOrNull(index + 1) + next != null && next.depth > tocEntries[index].depth + }.toSet() + } + var expandedEntryIndices by remember(tocEntries) { mutableStateOf(allParentIndices) } + val visibleItemInfo by remember(tocEntries) { + derivedStateOf { + val result = mutableListOf>() + val visibilityStack = BooleanArray(50) { false } + visibilityStack[0] = true + + tocEntries.forEachIndexed { index, entry -> + val depth = entry.depth.coerceIn(0, visibilityStack.lastIndex) + if (visibilityStack[depth]) { + result += index to entry + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = index in expandedEntryIndices + } + } else if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = false + } + } + result + } + } + val currentChapterIndex = session.reader.currentPage?.chapterIndex + val activeOriginalIndex = remember(tocEntries, chapters, currentChapterIndex) { + tocEntries.indexOfFirst { entry -> + val targetChapter = entry.targetChapterIndex(chapters) + targetChapter == currentChapterIndex + }.takeIf { it >= 0 } ?: currentChapterIndex?.takeIf { it in tocEntries.indices } + } + + fun expandParentsFor(originalIndex: Int) { + var currentDepth = tocEntries.getOrNull(originalIndex)?.depth ?: return + val nextExpanded = expandedEntryIndices.toMutableSet() + for (index in originalIndex downTo 0) { + val entry = tocEntries[index] + if (entry.depth < currentDepth) { + nextExpanded += index + currentDepth = entry.depth + } + if (currentDepth == 0) break + } + expandedEntryIndices = nextExpanded + } + + fun locateCurrent() { + val originalIndex = activeOriginalIndex ?: return + coroutineScope.launch { + expandParentsFor(originalIndex) + repeat(4) { + val visibleIndex = visibleItemInfo.indexOfFirst { it.first == originalIndex } + if (visibleIndex >= 0) { + listState.animateScrollToItem(visibleIndex) + return@launch + } + delay(30) + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { expandedEntryIndices = allParentIndices }) { + Text("Expand all") + } + TextButton(onClick = { expandedEntryIndices = emptySet() }) { + Text("Collapse all") + } + TextButton(onClick = ::locateCurrent, enabled = activeOriginalIndex != null) { + Text("Locate") + } + } + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - colors.forEach { color -> - FilterChip( - selected = highlight.color == color, - onClick = { onColorChange(highlight, color) }, - label = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .width(10.dp) - .height(10.dp) - .background(color.color, RoundedCornerShape(2.dp)) - ) - Text(color.id) + itemsIndexed( + visibleItemInfo, + key = { _, item -> "${item.first}_${item.second.href}_${item.second.fragmentId.orEmpty()}" } + ) { _, item -> + val (originalIndex, entry) = item + val nextItem = tocEntries.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.depth > entry.depth + val isExpanded = originalIndex in expandedEntryIndices + val targetChapterIndex = entry.targetChapterIndex(chapters) + val selected = targetChapterIndex == currentChapterIndex + + SharedReaderTocTreeItem( + title = entry.label, + pageLabel = targetChapterIndex?.let { "Ch. ${it + 1}" }, + depth = entry.depth, + isExpanded = isExpanded, + hasChildren = hasChildren, + isCurrent = selected, + onToggleExpand = { + expandedEntryIndices = if (isExpanded) { + expandedEntryIndices - originalIndex + } else { + expandedEntryIndices + originalIndex + } + }, + onClick = { + val chapterIndex = targetChapterIndex + if (chapterIndex != null) { + val fragment = entry.fragmentId + if (fragment.isNullOrBlank()) { + onGoToChapter(chapterIndex) + } else { + when (val target = readerEngine.resolveLink(session, "#$fragment", chapterIndex)) { + is ReaderLinkTarget.Internal -> onGoToLocator(target.locator) + else -> onGoToChapter(chapterIndex) + } + } } } ) } } - OutlinedTextField( - value = highlight.note.orEmpty(), - onValueChange = { onNoteChange(highlight, it) }, - label = { Text("Note") }, - maxLines = 2, - modifier = Modifier.fillMaxWidth() + SharedReaderVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) ) - TextButton(onClick = { onDelete(highlight) }) { - Text("Delete") + } + } +} + +@Composable +private fun SharedReaderTocTreeItem( + title: String, + pageLabel: String?, + depth: Int, + isExpanded: Boolean, + hasChildren: Boolean, + isCurrent: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + Surface( + color = if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 46.dp) + .padding(start = (depth.coerceAtLeast(0) * 14).dp) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clickable(enabled = hasChildren) { onToggleExpand() }, + contentAlignment = Alignment.Center + ) { + if (hasChildren) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Text( + title, + fontWeight = if (isCurrent) FontWeight.Bold else if (depth == 0) FontWeight.SemiBold else FontWeight.Normal, + color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (pageLabel != null) { + Text( + pageLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) } } } } +private fun SharedEpubTocEntry.targetChapterIndex( + chapters: List +): Int? { + val targetPath = href.normalizedReaderTocPath() + return chapters.indexOfFirst { chapter -> + val chapterPath = chapter.baseHref.orEmpty().normalizedReaderTocPath() + chapterPath == targetPath || + chapterPath.substringAfterLast('/') == targetPath.substringAfterLast('/') || + chapter.id == href + }.takeIf { it >= 0 } +} + +private fun String.normalizedReaderTocPath(): String { + return replace('\\', '/') + .substringBefore('#') + .substringBefore('?') + .trim('/') +} + +@Composable +private fun SharedReaderBookmarksTab( + session: ReaderSessionState, + onGoToBookmark: (ReaderBookmark) -> Unit +) { + if (session.bookmarks.isEmpty()) { + SharedReaderEmptyNavigation("No bookmarks yet") + } else { + val listState = rememberLazyListState() + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(session.bookmarks, key = { it.id }) { bookmark -> + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onGoToBookmark(bookmark) } + ) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + SharedReaderVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } +} + +@Composable +private fun SharedReaderAnnotationsTab( + session: ReaderSessionState, + onGoToHighlight: (UserHighlight) -> Unit, + onEditHighlight: (UserHighlight) -> Unit, + highlightPalette: ReaderHighlightPalette, + onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit, + onDeleteHighlight: (UserHighlight) -> Unit +) { + if (session.highlights.isEmpty()) { + SharedReaderEmptyNavigation("No annotations yet") + } else { + val listState = rememberLazyListState() + var menuExpandedFor by remember { mutableStateOf(null) } + var deleteConfirmFor by remember { mutableStateOf(null) } + val colors = highlightPalette.sanitized().colors + Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(session.highlights, key = { it.id }) { highlight -> + val locator = highlight.locator.withFallbacks( + chapterIndex = highlight.chapterIndex, + cfi = highlight.cfi, + textQuote = highlight.text + ) + val chapterTitle = session.reader.book.chapters + .getOrNull(locator.chapterIndex ?: highlight.chapterIndex) + ?.title + ?: "Chapter ${(locator.chapterIndex ?: highlight.chapterIndex) + 1}" + val pageLabel = locator.pageIndex?.let { "Page ${it + 1}" } + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp, end = 4.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .weight(1f) + .clickable { onGoToHighlight(highlight) }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(12.dp) + .height(12.dp) + .background(highlight.color.color, RoundedCornerShape(2.dp)) + ) + Text( + listOfNotNull(chapterTitle, pageLabel).joinToString(" - "), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + Box { + IconButton(onClick = { menuExpandedFor = highlight }) { + Icon(Icons.Default.MoreVert, contentDescription = "Annotation options") + } + DropdownMenu( + expanded = menuExpandedFor == highlight, + onDismissRequest = { menuExpandedFor = null } + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + colors.forEach { color -> + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(color.color) + .border( + width = if (highlight.color == color) 3.dp else 1.dp, + color = if (highlight.color == color) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, + shape = CircleShape + ) + .clickable { + menuExpandedFor = null + onHighlightColorChange(highlight, color) + } + ) + } + } + HorizontalDivider() + DropdownMenuItem( + text = { Text(if (highlight.note.isNullOrBlank()) "Add note" else "Edit note") }, + onClick = { + menuExpandedFor = null + onEditHighlight(highlight) + } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuExpandedFor = null + deleteConfirmFor = highlight + } + ) + } + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onGoToHighlight(highlight) }, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(highlight.text, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) + highlight.note?.takeIf { it.isNotBlank() }?.let { note -> + Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } + SharedReaderVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + deleteConfirmFor?.let { highlight -> + AlertDialog( + onDismissRequest = { deleteConfirmFor = null }, + title = { Text("Delete annotation?") }, + text = { Text("This removes the highlight and its note.") }, + confirmButton = { + TextButton( + onClick = { + deleteConfirmFor = null + onDeleteHighlight(highlight) + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteConfirmFor = null }) { + Text("Cancel") + } + } + ) + } + } + } +} + +@Composable +private fun SharedReaderEmptyNavigation(message: String) { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +private fun ReaderWorkspaceLeftSection.readerNavigationTabLabel(): String { + return when (this) { + ReaderWorkspaceLeftSection.CONTENTS -> "TOC" + ReaderWorkspaceLeftSection.NOTES -> "Annotations" + ReaderWorkspaceLeftSection.BOOKMARKS -> "Bookmarks" + ReaderWorkspaceLeftSection.PAGES -> "Pages" + ReaderWorkspaceLeftSection.SEARCH -> "Search" + } +} + @Composable private fun SharedHighlightPaletteEditor( palette: ReaderHighlightPalette, onPaletteChange: (ReaderHighlightPalette) -> Unit ) { val sanitized = palette.sanitized() - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Palette", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + var selectedSlotIndex by remember(sanitized.colors) { mutableIntStateOf(0) } + val colors = sanitized.colors + + fun replaceSlot(color: HighlightColor) { + if (colors.isEmpty()) return + val next = colors.toMutableList() + val slot = selectedSlotIndex.coerceIn(0, next.lastIndex) + val previousColor = next[slot] + val existingIndex = next.indexOf(color) + if (existingIndex >= 0 && existingIndex != slot) { + next[existingIndex] = previousColor + } + next[slot] = color + onPaletteChange(ReaderHighlightPalette(colors = next).sanitized()) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Tap a slot, then pick a color.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.horizontalScroll(rememberScrollState()) ) { - HighlightColor.entries.forEach { color -> - FilterChip( - selected = sanitized.contains(color), - onClick = { - onPaletteChange(sanitized.withColor(color, enabled = !sanitized.contains(color))) - }, - label = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .width(10.dp) - .height(10.dp) - .background(color.color, RoundedCornerShape(2.dp)) - ) - Text(color.id) - } + colors.forEachIndexed { index, color -> + val selected = index == selectedSlotIndex.coerceIn(0, colors.lastIndex) + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(color.color) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = CircleShape + ) + .clickable { selectedSlotIndex = index }, + contentAlignment = Alignment.Center + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = if (color.color.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(20.dp) + ) } - ) + } + } + } + HighlightColor.entries.chunked(7).forEach { rowColors -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + rowColors.forEach { color -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(color.color) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), CircleShape) + .clickable { replaceSlot(color) } + ) + } } } } @@ -2311,6 +3643,74 @@ private fun ReaderToolbarPreferences.moveTool(tool: ReaderTool, delta: Int): Rea return withToolOrder(order) } +private val ReaderSessionState.shouldShowJumpHistory: Boolean + get() = reader.settings.readingMode != ReaderReadingMode.PAGINATED && jumpHistory.hasJumpTargets + +@Composable +private fun SharedReaderJumpHistoryBar( + session: ReaderSessionState, + onBack: () -> Unit, + onForward: () -> Unit, + onClear: () -> Unit +) { + val history = session.jumpHistory + val back = history.backLocator + val forward = history.forwardLocator + if (back == null && forward == null) return + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = onBack, + enabled = back != null, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Jump back") + Text( + back?.jumpLabel(session).orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton( + onClick = onClear, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Close, contentDescription = "Clear jump history") + Text("Clear", maxLines = 1) + } + TextButton( + onClick = onForward, + enabled = forward != null, + modifier = Modifier.weight(1f) + ) { + Text( + forward?.jumpLabel(session).orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Jump forward") + } + } +} + +private fun ReaderLocator.jumpLabel(session: ReaderSessionState): String { + val targetPageIndex = pageIndex + val targetCfi = cfi.orEmpty() + if (targetPageIndex != null && targetCfi.isBlank()) { + return "Page ${targetPageIndex + 1}" + } + val chapter = chapterIndex + return if (chapter != null) { + session.reader.book.chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}" + } else { + "Location" + } +} + private fun Long.toComposeColor(): Color { val value = this and 0xFFFFFFFFL val alpha = ((value shr 24) and 0xFF) / 255f @@ -2321,10 +3721,10 @@ private fun Long.toComposeColor(): Color { } private fun PaginatedReaderState.pageInfoText(): String { - val current = currentPageIndex + 1 val total = pages.size.coerceAtLeast(1) val percent = progress.roundToInt().coerceIn(0, 100) val mode = if (settings.readingMode == ReaderReadingMode.VERTICAL) "Continuous" else "Page" + val current = ReaderSpreadLayout.pageRangeLabel(currentPageIndex, total, settings) val chapter = currentPage?.chapterTitle?.takeIf { it.isNotBlank() } return listOfNotNull("$mode $current of $total ($percent%)", chapter).joinToString(" - ") } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.kt new file mode 100644 index 0000000..b3f93ad --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.kt @@ -0,0 +1,55 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal data class SharedReaderModalAnchorBounds( + val leftPx: Float, + val topPx: Float, + val widthPx: Float, + val heightPx: Float +) + +internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf { null } + +internal enum class SharedReaderModalLevel { + Panel, + Popup +} + +val SharedReaderPopupDefaultMaxWidth = 440.dp +private val SharedReaderPopupMinWidth = 320.dp +private const val SharedReaderPopupWidthFraction = 0.58f + +fun sharedReaderPopupWidth( + availableWidth: Dp, + maxWidth: Dp = SharedReaderPopupDefaultMaxWidth, + minWidth: Dp = SharedReaderPopupMinWidth, + widthFraction: Float = SharedReaderPopupWidthFraction +): Dp { + if (availableWidth <= 0.dp) return 0.dp + val lowerBound = minWidth.coerceAtMost(availableWidth) + val upperBound = maxWidth.coerceAtMost(availableWidth).coerceAtLeast(lowerBound) + return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound) +} + +@Composable +internal expect fun SharedReaderModalLayer( + onDismiss: () -> Unit, + level: SharedReaderModalLevel = SharedReaderModalLevel.Popup, + content: @Composable () -> Unit +) + +@Composable +fun SharedReaderPopupLayer( + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + SharedReaderModalLayer( + onDismiss = onDismiss, + level = SharedReaderModalLevel.Popup, + content = content + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderScrollbars.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderScrollbars.kt new file mode 100644 index 0000000..d966e25 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderScrollbars.kt @@ -0,0 +1,440 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlin.math.abs +import kotlin.math.roundToInt + +private data class SharedScrollbarState( + val progress: Float, + val preferredThumbHeightPx: Float, + val contentHeightPx: Float, + val viewportHeightPx: Float +) + +private data class SharedPdfScrollbarState( + val progress: Float, + val contentHeightPx: Float, + val viewportHeightPx: Float +) + +@Composable +fun SharedReaderVerticalScrollbar( + listState: LazyListState, + modifier: Modifier = Modifier +) { + val scrollbarState by remember(listState) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val visibleItems = layoutInfo.visibleItemsInfo + val viewportHeight = layoutInfo.viewportSize.height.toFloat() + if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) { + return@derivedStateOf null + } + + val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size + val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight) + val viewportRatio = viewportHeight / contentHeight + if (viewportRatio >= 1f) return@derivedStateOf null + + val maxThumbHeight = viewportHeight / 2f + val minThumbHeight = minOf(80f, maxThumbHeight) + val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight) + val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) + + listState.firstVisibleItemScrollOffset + val maxScroll = contentHeight - viewportHeight + val progress = (currentScroll / maxScroll).coerceIn(0f, 1f) + + SharedScrollbarState( + progress = progress, + preferredThumbHeightPx = thumbHeight, + contentHeightPx = contentHeight, + viewportHeightPx = viewportHeight + ) + } + } + + val state = scrollbarState ?: return + val density = LocalDensity.current + var isDraggingScrollbar by remember { mutableStateOf(false) } + var scrollbarVisible by remember { mutableStateOf(false) } + var scrollInteractionTick by remember { mutableIntStateOf(0) } + var scrollbarTrackHeight by remember { mutableStateOf(0f) } + + LaunchedEffect(listState) { + var previousIndex = listState.firstVisibleItemIndex + var previousOffset = listState.firstVisibleItemScrollOffset + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + if (index != previousIndex || abs(offset - previousOffset) > 1) { + scrollInteractionTick += 1 + } + previousIndex = index + previousOffset = offset + } + } + + LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) { + if (isDraggingScrollbar) { + scrollbarVisible = true + } else if (scrollInteractionTick > 0) { + scrollbarVisible = true + delay(5_000) + scrollbarVisible = false + } + } + + val scrollbarAlpha by animateFloatAsState( + targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "sharedReaderScrollbarAlpha" + ) + val activeThemeColor = MaterialTheme.colorScheme.primary + val idleColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f) + val barColor by animateColorAsState( + targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor, + label = "sharedReaderScrollbarColor" + ) + val scrollbarIdleWidth = 4.dp + val scrollbarActiveWidth = 8.dp + val barWidth by animateDpAsState( + targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth, + label = "sharedReaderScrollbarWidth" + ) + val preferredThumbHeight = with(density) { state.preferredThumbHeightPx.toDp() } + val scrollbarIdleHeight = preferredThumbHeight.coerceAtLeast(40.dp) + val scrollbarActiveHeight = preferredThumbHeight.coerceAtLeast(60.dp) + val barHeight by animateDpAsState( + targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight, + label = "sharedReaderScrollbarHeight" + ) + + Box( + modifier = modifier + .fillMaxHeight() + .width(36.dp) + .padding(top = 8.dp, bottom = 8.dp) + .onGloballyPositioned { coordinates -> + scrollbarTrackHeight = coordinates.size.height.toFloat() + } + ) { + val thumbHeightPx = with(density) { barHeight.toPx() } + val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx + val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f) + val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace) + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = thumbY.roundToInt()) } + .align(Alignment.TopEnd) + .alpha(scrollbarAlpha) + .padding(end = 4.dp) + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .height(barHeight) + .width(36.dp) + .pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + try { + isDraggingScrollbar = true + scrollbarVisible = true + scrollInteractionTick += 1 + down.consume() + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + + val deltaY = change.position.y - change.previousPosition.y + if (deltaY != 0f) { + change.consume() + val trackHeight = scrollbarTrackHeight.takeIf { it > 0f } + ?: state.viewportHeightPx + val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() }) + .coerceAtLeast(1f) + val scrollDelta = (deltaY / trackSpace) * + (state.contentHeightPx - state.viewportHeightPx) + listState.dispatchRawDelta(scrollDelta) + scrollInteractionTick += 1 + } + } + } finally { + isDraggingScrollbar = false + scrollInteractionTick += 1 + } + } + } + ) { + Box( + modifier = Modifier + .size(width = barWidth, height = barHeight) + .background(barColor, RoundedCornerShape(999.dp)) + ) + } + } + } +} + +@Composable +fun SharedPdfVerticalScrollbar( + listState: LazyListState, + pageCount: Int, + currentPage: Int, + isDarkMode: Boolean, + modifier: Modifier = Modifier +) { + val scrollbarState by remember(listState) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val viewportHeight = layoutInfo.viewportSize.height.toFloat() + val totalItems = layoutInfo.totalItemsCount + if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) { + return@derivedStateOf null + } + + val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size + val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight) + val maxScroll = contentHeight - viewportHeight + if (maxScroll <= 1f) return@derivedStateOf null + + val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) + + listState.firstVisibleItemScrollOffset + SharedPdfScrollbarState( + progress = (currentScroll / maxScroll).coerceIn(0f, 1f), + contentHeightPx = contentHeight, + viewportHeightPx = viewportHeight + ) + } + } + + val state = scrollbarState ?: return + val density = LocalDensity.current + var isDraggingScrollbar by remember { mutableStateOf(false) } + var scrollbarVisible by remember { mutableStateOf(false) } + var scrollInteractionTick by remember { mutableIntStateOf(0) } + var scrollbarTrackHeight by remember { mutableStateOf(0f) } + + LaunchedEffect(listState) { + var previousIndex = listState.firstVisibleItemIndex + var previousOffset = listState.firstVisibleItemScrollOffset + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + if (index != previousIndex || abs(offset - previousOffset) > 1) { + scrollInteractionTick += 1 + } + previousIndex = index + previousOffset = offset + } + } + + LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) { + if (isDraggingScrollbar) { + scrollbarVisible = true + } else if (scrollInteractionTick > 0) { + scrollbarVisible = true + delay(5_000) + scrollbarVisible = false + } + } + + val scrollbarAlpha by animateFloatAsState( + targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "sharedPdfScrollbarAlpha" + ) + val activeThemeColor = if (isDarkMode) Color(0xFF1976D2) else Color(0xFF4285F4) + val idleColor = if (isDarkMode) Color.Gray else Color.DarkGray + val barColor by animateColorAsState( + targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor, + label = "sharedPdfScrollbarColor" + ) + val scrollbarIdleWidth = 4.dp + val scrollbarActiveWidth = 8.dp + val barWidth by animateDpAsState( + targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth, + label = "sharedPdfScrollbarWidth" + ) + val scrollbarIdleHeight = 40.dp + val scrollbarActiveHeight = 60.dp + val barHeight by animateDpAsState( + targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight, + label = "sharedPdfScrollbarHeight" + ) + val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0 + + Box( + modifier = modifier + .fillMaxHeight() + .width(48.dp) + .padding(top = 12.dp, bottom = 12.dp) + .onGloballyPositioned { coordinates -> + scrollbarTrackHeight = coordinates.size.height.toFloat() + } + ) { + val thumbHeightPx = with(density) { barHeight.toPx() } + val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx + val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f) + val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace) + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = thumbY.roundToInt()) } + .align(Alignment.TopEnd) + .wrapContentSize(align = Alignment.CenterEnd, unbounded = true) + .alpha(scrollbarAlpha) + .padding(end = 4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedVisibility( + visible = isDraggingScrollbar && pageCount > 0, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 } + ) { + Surface( + shape = CircleShape, + color = activeThemeColor, + shadowElevation = 4.dp, + modifier = Modifier.padding(end = 12.dp) + ) { + Text( + text = "${safeCurrentPage + 1}/$pageCount", + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ), + color = Color.White, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .height(barHeight) + .width(48.dp) + .pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + try { + isDraggingScrollbar = true + scrollbarVisible = true + scrollInteractionTick += 1 + down.consume() + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + + val deltaY = change.position.y - change.previousPosition.y + if (deltaY != 0f) { + change.consume() + val trackHeight = scrollbarTrackHeight.takeIf { it > 0f } + ?: state.viewportHeightPx + val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() }) + .coerceAtLeast(1f) + val scrollDelta = (deltaY / trackSpace) * + (state.contentHeightPx - state.viewportHeightPx) + listState.dispatchRawDelta(scrollDelta) + scrollInteractionTick += 1 + } + } + } finally { + isDraggingScrollbar = false + scrollInteractionTick += 1 + } + } + } + ) { + Box( + modifier = Modifier + .size(width = barWidth, height = barHeight) + .background(barColor, RoundedCornerShape(999.dp)) + ) + } + } + } + } +} + +fun Modifier.sharedAcceleratedLazyWheelScroll( + listState: LazyListState, + multiplier: Float = 4f +): Modifier { + val safeMultiplier = multiplier.coerceIn(1f, 12f) + return pointerInput(listState, safeMultiplier) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + if (event.type != PointerEventType.Scroll) continue + val scrollDelta = event.changes.fold(0f) { total, change -> + val delta = change.scrollDelta + total + if (abs(delta.y) >= abs(delta.x)) delta.y else delta.x + } + if (abs(scrollDelta) > 0.01f) { + val adaptiveMultiplier = when { + abs(scrollDelta) < 1f -> 24f + abs(scrollDelta) < 8f -> 10f + else -> safeMultiplier + } + listState.dispatchRawDelta(scrollDelta * (adaptiveMultiplier - 1f)) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSettingsHub.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSettingsHub.kt new file mode 100644 index 0000000..1e0a2be --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSettingsHub.kt @@ -0,0 +1,723 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BuiltInPdfReaderThemes +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.ReaderAction +import com.aryan.reader.shared.ReaderToolbarPreferences +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.SharedSettingsAction +import com.aryan.reader.shared.SharedSettingsCategoryModel +import com.aryan.reader.shared.SharedSettingsDestination +import com.aryan.reader.shared.SharedSettingsHubModel +import com.aryan.reader.shared.SharedSettingsItemKind +import com.aryan.reader.shared.SharedSettingsItemModel +import com.aryan.reader.shared.SharedSettingsPageKind +import com.aryan.reader.shared.SharedSettingsPageModel +import com.aryan.reader.shared.SharedSettingsSearchResult +import com.aryan.reader.shared.parentDestination +import com.aryan.reader.shared.reader.ReaderSettings + +@Composable +fun SharedSettingsHub( + model: SharedSettingsHubModel, + query: String, + onQueryChange: (String) -> Unit, + readerDefaultSettings: ReaderSettings, + onReaderDefaultSettingsChange: (ReaderSettings) -> Unit, + pdfReaderDefaultSettings: ReaderSettings = readerDefaultSettings, + onPdfReaderDefaultSettingsChange: (ReaderSettings) -> Unit = onReaderDefaultSettingsChange, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + onAction: (SharedSettingsAction) -> Unit, + modifier: Modifier = Modifier, + readerToolbarPreferences: ReaderToolbarPreferences? = null, + onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {}, + customFonts: List = emptyList(), + onPickCustomFont: (() -> String?)? = null, + readerCustomTextureIds: List = emptyList(), + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + showTopBar: Boolean = true, + onBack: (() -> Unit)? = null, + destination: SharedSettingsDestination = SharedSettingsDestination.ROOT, + onDestinationChange: (SharedSettingsDestination) -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + val page = remember(model, destination) { model.page(destination) } + val searchResults = remember(model, query) { model.searchResults(query) } + + fun navigateTo(next: SharedSettingsDestination) { + onQueryChange("") + onDestinationChange(next) + } + + fun navigateUp() { + if (query.isNotBlank()) { + onQueryChange("") + return + } + val parent = destination.parentDestination() + if (parent != null) { + onDestinationChange(parent) + } else { + onBack?.invoke() + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .padding(contentPadding) + ) { + val contentWidth = if (maxWidth >= 960.dp) Modifier.width(860.dp) else Modifier.fillMaxWidth() + Column( + modifier = contentWidth + .fillMaxHeight() + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + if (showTopBar) { + SharedSettingsHeader( + page = page, + canNavigateUp = query.isNotBlank() || destination != SharedSettingsDestination.ROOT || onBack != null, + onNavigateUp = ::navigateUp + ) + } + + SharedStableOutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + label = { Text("Search settings") } + ) + + when { + query.isNotBlank() -> SharedSettingsSearchResults( + results = searchResults, + onNavigate = ::navigateTo, + onAction = onAction, + modifier = Modifier.weight(1f) + ) + page.kind == SharedSettingsPageKind.ROOT -> SharedSettingsCategoryList( + categories = page.categories, + onNavigate = ::navigateTo, + modifier = Modifier.weight(1f) + ) + page.kind == SharedSettingsPageKind.CATEGORY -> SharedSettingsItemList( + page = page, + onNavigate = ::navigateTo, + onAction = onAction, + modifier = Modifier.weight(1f) + ) + else -> SharedSettingsDetailPage( + page = page, + settings = readerDefaultSettings, + onSettingsChange = onReaderDefaultSettingsChange, + pdfSettings = pdfReaderDefaultSettings, + onPdfSettingsChange = onPdfReaderDefaultSettingsChange, + toolbarPreferences = readerToolbarPreferences, + onToolbarPreferencesChange = onReaderToolbarPreferencesChange, + ttsReplacementPreferences = ttsReplacementPreferences, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + customFonts = customFonts, + onPickCustomFont = onPickCustomFont, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = onImportReaderTexture, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun SharedSettingsHeader( + page: SharedSettingsPageModel, + canNavigateUp: Boolean, + onNavigateUp: () -> Unit +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (canNavigateUp) { + TextButton(onClick = onNavigateUp) { + Text("Back") + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(page.title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + page.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SharedSettingsCategoryList( + categories: List, + onNavigate: (SharedSettingsDestination) -> Unit, + modifier: Modifier +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + categories.forEach { category -> + item(key = category.destination.name) { + SharedSettingsCategoryRow(category = category, onNavigate = onNavigate) + } + } + } +} + +@Composable +private fun SharedSettingsCategoryRow( + category: SharedSettingsCategoryModel, + onNavigate: (SharedSettingsDestination) -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), + onClick = { onNavigate(category.destination) } + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon( + category.destination.iconForSettingsDestination(), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(category.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + category.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + if (category.itemCount == 1) "1 setting" else "${category.itemCount} settings", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp)) + } + } +} + +@Composable +private fun SharedSettingsItemList( + page: SharedSettingsPageModel, + onNavigate: (SharedSettingsDestination) -> Unit, + onAction: (SharedSettingsAction) -> Unit, + modifier: Modifier +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item(key = page.destination.name) { + SharedSettingsGroup { + page.items.forEachIndexed { index, item -> + SharedSettingsRow( + item = item, + onNavigate = onNavigate, + onAction = onAction + ) + if (index != page.items.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + } + } + } + } + } +} + +@Composable +private fun SharedSettingsSearchResults( + results: List, + onNavigate: (SharedSettingsDestination) -> Unit, + onAction: (SharedSettingsAction) -> Unit, + modifier: Modifier +) { + if (results.isEmpty()) { + SharedSettingsEmptySearch(modifier = modifier) + return + } + + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + SharedSettingsGroup { + results.forEachIndexed { index, result -> + SharedSettingsSearchResultRow( + result = result, + onNavigate = onNavigate, + onAction = onAction + ) + if (index != results.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + } + } + } + } + } +} + +@Composable +private fun SharedSettingsEmptySearch(modifier: Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(36.dp)) + Text("No settings found", fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun SharedSettingsSearchResultRow( + result: SharedSettingsSearchResult, + onNavigate: (SharedSettingsDestination) -> Unit, + onAction: (SharedSettingsAction) -> Unit +) { + val contentColor = if (result.enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + enabled = result.enabled, + onClick = { + result.destination?.let(onNavigate) ?: result.action?.let(onAction) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + result.action?.iconForSettings() ?: result.destination?.iconForSettingsDestination() ?: Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (result.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(result.title, fontWeight = FontWeight.SemiBold, color = contentColor) + Text( + result.breadcrumb, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + result.summary, + style = MaterialTheme.typography.bodySmall, + color = if (result.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + when (result.kind) { + SharedSettingsItemKind.TOGGLE -> { + Switch( + checked = result.checked == true, + enabled = result.enabled, + onCheckedChange = { result.action?.let(onAction) } + ) + } + else -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp)) + } + } + } +} + +@Composable +private fun SharedSettingsGroup(content: @Composable () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + content() + } + } +} + +@Composable +private fun SharedSettingsRow( + item: SharedSettingsItemModel, + onNavigate: (SharedSettingsDestination) -> Unit, + onAction: (SharedSettingsAction) -> Unit +) { + val contentColor = if (item.enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + enabled = item.enabled, + onClick = { + item.destination?.let(onNavigate) ?: onAction(item.action) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 2.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + item.action.iconForSettings(), + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (item.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(item.title, fontWeight = FontWeight.SemiBold, color = contentColor) + Text( + item.summary, + style = MaterialTheme.typography.bodySmall, + color = if (item.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + when (item.kind) { + SharedSettingsItemKind.TOGGLE -> { + Switch( + checked = item.checked == true, + enabled = item.enabled, + onCheckedChange = { onAction(item.action) } + ) + } + SharedSettingsItemKind.INFO -> Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) + SharedSettingsItemKind.DESTRUCTIVE, + SharedSettingsItemKind.NAVIGATION, + SharedSettingsItemKind.CONTROL -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp)) + } + } + } +} + +@Composable +private fun SharedSettingsDetailPage( + page: SharedSettingsPageModel, + settings: ReaderSettings, + onSettingsChange: (ReaderSettings) -> Unit, + pdfSettings: ReaderSettings, + onPdfSettingsChange: (ReaderSettings) -> Unit, + toolbarPreferences: ReaderToolbarPreferences?, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + customFonts: List, + onPickCustomFont: (() -> String?)?, + readerCustomTextureIds: List, + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, + modifier: Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + page.localOverrideNote?.let { note -> + SharedSettingsLocalOverrideNote(note) + } + + SharedSettingsDetailSurface { + when (page.destination) { + SharedSettingsDestination.EPUB_FORMAT -> { + SharedReaderFormatControls( + settings = settings, + toolbarPreferences = ReaderToolbarPreferences(), + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + onReaderAction = { action -> + if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings) + } + ) + } + SharedSettingsDestination.EPUB_THEME_TEXTURE -> { + SharedReaderThemeControls( + settings = settings, + customTextureIds = readerCustomTextureIds, + onImportTexture = onImportReaderTexture, + onSettingsChange = onSettingsChange + ) + } + SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> { + SharedReaderVisualOptionsControls( + settings = settings, + onReaderAction = { action -> + if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings) + } + ) + } + SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Fixed-layout appearance", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SharedReaderThemeControls( + settings = pdfSettings, + builtInThemes = BuiltInPdfReaderThemes, + customTextureIds = readerCustomTextureIds, + onImportTexture = onImportReaderTexture, + onSettingsChange = onPdfSettingsChange + ) + HorizontalDivider() + Text("Visual options", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + SharedPdfVisualOptionDefaultsSwitch( + title = "Remove gap between pages", + summary = "Applies to vertical PDF reading mode.", + checked = !pdfSettings.pdfVerticalPageGapVisible, + onCheckedChange = { removeGap -> + onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap)) + } + ) + SharedPdfVisualOptionDefaultsSwitch( + title = "Hide page number overlay", + summary = "Removes the small page count label from each PDF page.", + checked = !pdfSettings.pdfPageNumberOverlayVisible, + onCheckedChange = { hideOverlay -> + onPdfSettingsChange(pdfSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay)) + } + ) + } + } + SharedSettingsDestination.PDF_READER_TOOLS -> { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Reader-managed PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> { + if (toolbarPreferences == null) { + Text("Reader toolbar defaults are managed from the reader on this platform.") + } else { + SharedReaderToolbarControls( + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + } + } + SharedSettingsDestination.EPUB_TTS_REPLACEMENTS, + SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> { + SharedReaderTtsReplacementControls( + preferences = ttsReplacementPreferences, + bookId = "global", + onPreferencesChange = onTtsReplacementPreferencesChange, + allowBookScope = false + ) + } + else -> Text(page.summary, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun SharedSettingsDetailSurface(content: @Composable () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + content() + } + } +} + +@Composable +private fun SharedSettingsLocalOverrideNote(note: SharedSettingsItemModel) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.42f) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(note.title, fontWeight = FontWeight.SemiBold) + Text(note.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun SharedPdfVisualOptionDefaultsSwitch( + title: String, + summary: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text( + summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +private fun SharedSettingsDestination.iconForSettingsDestination(): ImageVector { + return when (this) { + SharedSettingsDestination.EPUB_TEXT, + SharedSettingsDestination.EPUB_FORMAT, + SharedSettingsDestination.EPUB_TTS_REPLACEMENTS, + SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> Icons.Default.TextFields + SharedSettingsDestination.PDF_COMICS, + SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS, + SharedSettingsDestination.PDF_READER_TOOLS, + SharedSettingsDestination.EPUB_THEME_TEXTURE, + SharedSettingsDestination.EPUB_VISUAL_DEFAULTS, + SharedSettingsDestination.READER_TOOLBAR_DEFAULTS, + SharedSettingsDestination.THEME_APPEARANCE -> Icons.Default.Palette + SharedSettingsDestination.TTS_AI -> Icons.Default.Settings + SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> Icons.Default.Folder + SharedSettingsDestination.SYNC_ACCOUNTS -> Icons.Default.Cloud + SharedSettingsDestination.EXTRA -> Icons.Default.Settings + SharedSettingsDestination.HELP_ABOUT -> Icons.Default.Info + SharedSettingsDestination.ROOT -> Icons.Default.Settings + } +} + +private fun SharedSettingsAction.iconForSettings(): ImageVector { + return when (this) { + SharedSettingsAction.TEXT_READER_DEFAULTS, + SharedSettingsAction.TTS_REPLACEMENTS, + SharedSettingsAction.TTS_SETTINGS, + SharedSettingsAction.HIDE_READER_AI -> Icons.Default.TextFields + SharedSettingsAction.PDF_READER_DEFAULTS, + SharedSettingsAction.READER_TOOLBAR, + SharedSettingsAction.APP_THEME -> Icons.Default.Palette + SharedSettingsAction.CUSTOM_FONTS -> Icons.Default.TextFields + SharedSettingsAction.SIGN_IN, + SharedSettingsAction.SIGN_OUT, + SharedSettingsAction.CLOUD_SYNC -> Icons.Default.Cloud + SharedSettingsAction.FOLDER_SYNC -> Icons.Default.Folder + SharedSettingsAction.CLEAR_BOOK_CACHE, + SharedSettingsAction.CLEAR_REFLOW_CACHE, + SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> Icons.Default.Delete + SharedSettingsAction.HELP_FEEDBACK -> Icons.Default.Feedback + SharedSettingsAction.SUPPORT -> Icons.Default.Favorite + SharedSettingsAction.LOCAL_OVERRIDE_NOTE, + SharedSettingsAction.ABOUT -> Icons.Default.Info + SharedSettingsAction.EXPORT_LOGS, + SharedSettingsAction.DEBUG_ACTIONS, + SharedSettingsAction.TEST_PANEL_DETECTION, + SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, + SharedSettingsAction.DEVICE_MANAGEMENT, + SharedSettingsAction.AI_SETTINGS, + SharedSettingsAction.LANGUAGE, + SharedSettingsAction.TABS_TOGGLE, + SharedSettingsAction.RECENT_LIMIT, + SharedSettingsAction.STRICT_FILE_FILTER, + SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, + SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> Icons.Default.Settings + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedStableTextFields.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedStableTextFields.kt new file mode 100644 index 0000000..4d6ee53 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedStableTextFields.kt @@ -0,0 +1,83 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation + +@Composable +fun SharedStableOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + shape: Shape = OutlinedTextFieldDefaults.shape, + selectionKey: Any? = Unit +) { + var fieldValue by remember(selectionKey) { + mutableStateOf(value.toTextFieldValueWithCursorAtEnd()) + } + + LaunchedEffect(selectionKey, value) { + if (value != fieldValue.text) { + fieldValue = value.toTextFieldValueWithCursorAtEnd() + } + } + + OutlinedTextField( + value = fieldValue, + onValueChange = { nextValue -> + fieldValue = nextValue + if (nextValue.text != value) { + onValueChange(nextValue.text) + } + }, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + minLines = minLines, + maxLines = if (singleLine) 1 else maxLines.coerceAtLeast(minLines), + shape = shape + ) +} + +private fun String.toTextFieldValueWithCursorAtEnd(): TextFieldValue { + return TextFieldValue( + text = this, + selection = TextRange(length) + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt index c287936..cee44cd 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -207,7 +206,7 @@ private fun SharedGoogleFontsDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp) ) { - OutlinedTextField( + SharedStableOutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier.fillMaxWidth(), @@ -435,8 +434,8 @@ fun SharedSupportProjectScreen( fun SharedAboutScreen( versionName: String, buildLabel: String, - onOpenSource: () -> Unit, - onOpenIssues: () -> Unit, + onOpenSource: (() -> Unit)? = null, + onOpenIssues: (() -> Unit)? = null, modifier: Modifier = Modifier ) { SharedScreenScaffold( @@ -471,18 +470,22 @@ fun SharedAboutScreen( } } } - SharedUtilityOptionCard( - title = "Source Code", - body = "Browse the project source on GitHub.", - icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) }, - onClick = onOpenSource - ) - SharedUtilityOptionCard( - title = "Issues", - body = "Open the issue tracker for bugs and feature requests.", - icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) }, - onClick = onOpenIssues - ) + if (onOpenSource != null) { + SharedUtilityOptionCard( + title = "Source Code", + body = "Browse the project source on GitHub.", + icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenSource + ) + } + if (onOpenIssues != null) { + SharedUtilityOptionCard( + title = "Issues", + body = "Open the issue tracker for bugs and feature requests.", + icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenIssues + ) + } } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt index 3c22ea1..9b1755c 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt @@ -153,9 +153,11 @@ class EpubAnnotationSerializerTest { fun `highlight bridge parser accepts raw or wrapped json payloads`() { val payload = """{"cfi":"desktop:0:4:9","text":"word","colorId":"yellow","chapterIndex":0,"locator":{"chapterIndex":0,"startOffset":4,"endOffset":9,"textQuote":"word","cfi":"desktop:0:4:9"}}""" val wrappedPayload = "\"${payload.replace("\"", "\\\"")}\"" + val arrayPayload = "[$wrappedPayload]" assertEquals(4, EpubAnnotationSerializer.parseHighlightJsonLenient(payload)?.locator?.startOffset) assertEquals(9, EpubAnnotationSerializer.parseHighlightJsonLenient(wrappedPayload)?.locator?.endOffset) + assertEquals("word", EpubAnnotationSerializer.parseHighlightJsonLenient(arrayPayload)?.text) } @Test diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt index aaee5bf..3abd561 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt @@ -2,6 +2,8 @@ package com.aryan.reader.shared import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class FileCapabilitiesTest { @@ -12,6 +14,17 @@ class FileCapabilitiesTest { PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES, SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) ) + assertFalse(FileType.UNKNOWN in SharedFileCapabilities.knownFileTypes) + assertFalse(FileType.UNKNOWN in SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)) + assertNull(SharedFileCapabilities.primaryExtensionFor(FileType.UNKNOWN)) + assertNull(SharedFileCapabilities.mimeTypeFor(FileType.UNKNOWN)) + assertEquals("epub", SharedFileCapabilities.primaryExtensionFor(FileType.EPUB)) + assertEquals("application/pdf", SharedFileCapabilities.mimeTypeFor(FileType.PDF)) + assertEquals("pptx", SharedFileCapabilities.primaryExtensionFor(FileType.PPTX)) + assertEquals( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + SharedFileCapabilities.mimeTypeFor(FileType.PPTX) + ) assertEquals( setOf( FileType.EPUB, @@ -42,6 +55,11 @@ class FileCapabilitiesTest { ReaderFeatureSurface.PDF_VIEWER, SharedFileCapabilities.surfaceFor(FileType.PDF, ReaderPlatform.DESKTOP) ) + assertEquals( + ReaderFeatureSurface.PDF_VIEWER, + SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.ANDROID) + ) + assertNull(SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.DESKTOP)) assertEquals( ReaderFeatureSurface.TEXT_READER, SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP) @@ -68,11 +86,27 @@ class FileCapabilitiesTest { assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("chapter.xhtml")) assertEquals(FileType.HTML, "chapter.xhtml".toFileType()) assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3")) + assertEquals(FileType.FB2, SharedFileCapabilities.fileTypeForName("book.fb2.zip")) + assertEquals(FileType.PPTX, SharedFileCapabilities.fileTypeForName("slides.pptx")) + assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("payload.json.txt")) + assertEquals(FileType.EPUB, SharedFileCapabilities.fileTypeForName("book.epub.txt")) assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip")) } + @Test + fun `shared file name policy detects manual only files and suffixes`() { + assertTrue(SharedFileCapabilities.isCodeOrDataFileName("table.csv")) + assertTrue(SharedFileCapabilities.isManualOnlyReaderFileName("script.kt.txt")) + assertFalse(SharedFileCapabilities.isManualOnlyReaderFileName("chapter.html")) + assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("table.csv", "text/csv")) + assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("payload", "application/json")) + assertTrue(SharedFileCapabilities.isLocalFolderSyncEligibleFile("book.fodt", "text/xml")) + assertEquals(".md.txt", SharedFileCapabilities.fileExtensionSuffixForName("notes.md.txt")) + assertEquals(".fb2.zip.txt", SharedFileCapabilities.fileExtensionSuffixForName("book.fb2.zip.txt")) + } + @Test fun `desktop parity gaps list Android readable formats not yet available on desktop`() { - assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps()) + assertEquals(listOf(FileType.PPTX), SharedFileCapabilities.desktopParityGaps()) } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt index 0684eb2..794fa22 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt @@ -20,6 +20,17 @@ class LocalFolderSyncEngineTest { ) } + @Test + fun `local folder sidecar filenames stay short for long book ids`() { + val bookId = "local_" + "Very Long Book Name ".repeat(20) + ".pdf" + + assertEquals(".book_37739e3be68f.json", localFolderSyncMetadataFileName(bookId)) + assertEquals(".book_37739e3be68f.tmp", localFolderSyncMetadataTempFileName(bookId)) + assertEquals(".book_37739e3be68f_annotations.json", localFolderSyncAnnotationFileName(bookId)) + assertEquals(".book_37739e3be68f_annotations.tmp", localFolderSyncAnnotationTempFileName(bookId)) + assertTrue(localFolderSyncAnnotationFileName(bookId).length < 80) + } + @Test fun `sync imports scanned folder books with remote metadata`() { val state = SharedReaderScreenState() @@ -42,7 +53,7 @@ class LocalFolderSyncEngineTest { val book = result.state.rawLibraryBooks.single() assertEquals("local_Book.pdf", book.id) - assertEquals("Remote Title", book.title) + assertEquals("Book", book.title) assertEquals(4, book.lastPageIndex) assertEquals(25f, book.progressPercentage) assertEquals("C:/Library", book.sourceFolder) @@ -73,7 +84,7 @@ class LocalFolderSyncEngineTest { ) val book = result.state.rawLibraryBooks.single() - assertEquals("Remote", book.title) + assertEquals("Local", book.title) assertEquals(80f, book.progressPercentage) assertEquals(1, result.stats.remoteMetadataUpdates) } @@ -107,6 +118,31 @@ class LocalFolderSyncEngineTest { assertEquals(0, result.stats.remoteMetadataUpdates) } + @Test + fun `sidecar display name survives physical folder scan`() { + val existing = book( + id = "local_Book.pdf", + timestamp = 500L, + displayName = "Reader Name", + title = "Local" + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf")), + remoteMetadata = mapOf( + "local_Book.pdf" to metadata( + id = "local_Book.pdf", + displayName = "Reader Name", + modified = 500L + ) + ), + nowMillis = 1_000L + ) + + assertEquals("Reader Name", result.state.rawLibraryBooks.single().displayName) + } + @Test fun `sync migrates desktop path ids and preserves references`() { val oldId = "C:/Library/Series/Book.pdf" @@ -173,12 +209,154 @@ class LocalFolderSyncEngineTest { assertEquals(1, result.stats.removedBooks) } + @Test + fun `sync ignores unknown scanned files even with default allowed types`() { + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(), + folder = SyncedFolder( + uriString = "C:/Library", + name = "Library", + lastScanTime = 0L + ), + files = listOf(scannedFile("archive.zip", "archive.zip", type = FileType.UNKNOWN)), + remoteMetadata = emptyMap(), + nowMillis = 1_000L + ) + + assertTrue(result.state.rawLibraryBooks.isEmpty()) + assertEquals(0, result.stats.supportedFiles) + assertEquals(0, result.stats.newBooks) + } + + @Test + fun `default synced folder allowed types exclude unknown`() { + assertFalse(FileType.UNKNOWN in SyncedFolder("C:/Library", "Library", lastScanTime = 0L).allowedFileTypes) + } + @Test fun `metadata sidecar is skipped for clean unread folder books`() { assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata()) assertNotNull(book(id = "local_Book.pdf", isRecent = true).toSharedFolderBookMetadata()) } + @Test + fun `metadata sidecar preserves precise reader position`() { + val locator = ReaderLocator( + chapterIndex = 2, + pageIndex = 7, + startOffset = 320, + endOffset = 320, + cfi = "desktop:2:320:320" + ) + + val metadata = book( + id = "local_Book.pdf", + progress = 45f, + readerPosition = locator + ).toSharedFolderBookMetadata() ?: error("Expected sidecar") + val restored = metadata.toBookItem( + file = scannedFile("Book.pdf", "Book.pdf"), + existing = null, + nowMillis = 2_000L + ) + + assertEquals(2, metadata.lastChapterIndex) + assertEquals(7, metadata.lastPage) + assertEquals("desktop:2:320:320", metadata.lastPositionCfi) + assertNull(metadata.locatorBlockIndex) + assertNull(metadata.locatorCharOffset) + assertEquals(locator, restored.readerPosition) + } + + @Test + fun `metadata sidecar ignores legacy editable metadata`() { + val local = book(id = "local_Book.pdf") + .copy( + isRecent = true, + title = "Edited Title", + author = "Edited Author", + seriesName = "Edited Series", + seriesIndex = 2.0, + description = "

Edited summary

", + originalTitle = "Original Title", + originalAuthor = "Original Author", + originalSeriesName = "Original Series", + originalSeriesIndex = 1.0, + originalDescription = "Original summary" + ) + + val metadata = local.toSharedFolderBookMetadata() ?: error("Expected sidecar") + val legacyMetadata = metadata.copy( + title = "Legacy Sidecar Title", + author = "Legacy Sidecar Author", + seriesName = "Legacy Sidecar Series", + seriesIndex = 2.0, + description = "

Legacy summary

", + originalTitle = "Legacy Original Title", + originalAuthor = "Legacy Original Author", + originalSeriesName = "Legacy Original Series", + originalSeriesIndex = 1.0, + originalDescription = "Legacy original summary" + ) + val restored = metadata.toBookItem( + file = scannedFile("Book.pdf", "Book.pdf"), + existing = book(id = "local_Book.pdf", title = "Stale"), + nowMillis = 2_000L + ) + val restoredFromLegacy = legacyMetadata.toBookItem( + file = scannedFile("Book.pdf", "Book.pdf"), + existing = book(id = "local_Book.pdf", title = "Stale"), + nowMillis = 2_000L + ) + + assertNull(metadata.title) + assertNull(metadata.description) + assertNull(metadata.originalTitle) + assertEquals("Stale", restored.title) + assertNull(restored.author) + assertNull(restored.seriesName) + assertNull(restored.description) + assertEquals("Stale", restoredFromLegacy.title) + assertNull(restoredFromLegacy.author) + } + + @Test + fun `sync resets extracted metadata and cover when folder file modified time changes`() { + val existing = book( + id = "local_Book.pdf", + fileSize = 123L, + title = "Extracted title", + coverImagePath = "C:/Covers/book.png", + folderTextMetadataParsed = true + ).copy( + author = "Extracted author", + description = "Extracted summary", + seriesName = "Extracted series", + seriesIndex = 1.0, + originalTitle = "Extracted title", + originalAuthor = "Extracted author", + originalDescription = "Extracted summary", + fileContentModifiedTimestamp = 100L + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 123L, lastModified = 500L)), + remoteMetadata = emptyMap(), + nowMillis = 1_000L + ) + + val updated = result.state.rawLibraryBooks.single() + assertNull(updated.coverImagePath) + assertFalse(updated.folderTextMetadataParsed) + assertEquals(500L, updated.fileContentModifiedTimestamp) + assertEquals("Book", updated.title) + assertNull(updated.author) + assertNull(updated.description) + assertNull(updated.seriesName) + assertNull(updated.originalTitle) + } + @Test fun `sync resets extracted metadata and cover when folder file size changes`() { val existing = book( @@ -214,16 +392,18 @@ class LocalFolderSyncEngineTest { private fun scannedFile( name: String, relativePath: String, - size: Long = 123L + size: Long = 123L, + type: FileType = FileType.PDF, + lastModified: Long = 100L ): SharedFolderScannedFile { return SharedFolderScannedFile( name = name, path = "C:/Library/$relativePath", sourceFolder = "C:/Library", relativePath = relativePath, - type = FileType.PDF, + type = type, size = size, - lastModified = 100L + lastModified = lastModified ) } @@ -238,7 +418,8 @@ class LocalFolderSyncEngineTest { isRecent: Boolean = false, fileSize: Long = 0L, coverImagePath: String? = null, - folderTextMetadataParsed: Boolean = false + folderTextMetadataParsed: Boolean = false, + readerPosition: ReaderLocator? = null ): BookItem { return BookItem( id = id, @@ -250,15 +431,18 @@ class LocalFolderSyncEngineTest { title = title, progressPercentage = progress, fileSize = fileSize, + fileContentModifiedTimestamp = 100L, sourceFolder = sourceFolder, isRecent = isRecent, - folderTextMetadataParsed = folderTextMetadataParsed + folderTextMetadataParsed = folderTextMetadataParsed, + readerPosition = readerPosition ) } private fun metadata( id: String, title: String = "Book", + displayName: String = "Book.pdf", lastPage: Int? = null, progress: Float = 0f, modified: Long @@ -267,7 +451,7 @@ class LocalFolderSyncEngineTest { bookId = id, title = title, author = null, - displayName = "Book.pdf", + displayName = displayName, type = FileType.PDF.name, lastChapterIndex = null, lastPage = lastPage, diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt index af6bc74..d80918f 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt @@ -37,10 +37,14 @@ class ReaderActionReducerTest { val searched = chapterTwo.reduce(ReaderAction.SearchChanged("needle"), engine) assertTrue(searched.searchResults.size >= 2) - assertTrue(searched.activeSearchResultIndex >= 0) + assertEquals(-1, searched.activeSearchResultIndex) + assertEquals(chapterTwo.reader.currentPageIndex, searched.reader.currentPageIndex) val nextSearch = searched.reduce(ReaderAction.NextSearchResult, engine) - assertEquals(searched.activeSearchResultIndex + 1, nextSearch.activeSearchResultIndex) + assertEquals( + searched.searchResults.indexOfFirst { it.pageIndex >= searched.reader.currentPageIndex }, + nextSearch.activeSearchResultIndex + ) val directSearch = searched.reduce(ReaderAction.GoToSearchResult(0), engine) assertEquals(0, directSearch.activeSearchResultIndex) @@ -86,11 +90,16 @@ class ReaderActionReducerTest { val closed = hiddenPanel.reduce(ReaderAction.SearchClosed, engine) assertTrue(opened.isSearchActive) + assertTrue(opened.showSearchResultsPanel) assertEquals(2, caseSensitive.searchResults.size) + assertEquals(-1, caseSensitive.activeSearchResultIndex) + assertEquals(session.reader.currentPageIndex, caseSensitive.reader.currentPageIndex) assertEquals(1, wholeWords.searchResults.size) assertEquals(false, hiddenPanel.showSearchResultsPanel) assertEquals("", closed.searchQuery) assertTrue(closed.searchResults.isEmpty()) + assertEquals(-1, closed.activeSearchResultIndex) + assertTrue(closed.showSearchResultsPanel) } @Test diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderDefaultSettingsStateTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderDefaultSettingsStateTest.kt new file mode 100644 index 0000000..98a5298 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderDefaultSettingsStateTest.kt @@ -0,0 +1,84 @@ +package com.aryan.reader.shared + +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReaderDefaultSettingsStateTest { + + @Test + fun `epub reader defaults to vertical mode`() { + assertEquals(ReaderReadingMode.VERTICAL, ReaderSettings().readingMode) + } + + @Test + fun `reader default settings reducer updates shared state`() { + val defaults = ReaderSettings( + fontSize = 24, + readingMode = ReaderReadingMode.VERTICAL, + textAlign = SharedReaderTextAlign.JUSTIFY, + themeId = "sepia" + ) + + val state = SharedReaderScreenState() + .reduce(AppAction.ReaderDefaultSettingsChanged(defaults)) + + assertEquals(defaults, state.readerDefaultSettings) + } + + @Test + fun `pdf reader default settings reducer updates separate shared state`() { + val epubDefaults = ReaderSettings(themeId = "sepia") + val pdfDefaults = ReaderSettings(themeId = "reverse") + + val state = SharedReaderScreenState(readerDefaultSettings = epubDefaults) + .reduce(AppAction.PdfReaderDefaultSettingsChanged(pdfDefaults)) + + assertEquals(epubDefaults, state.readerDefaultSettings) + assertEquals(pdfDefaults, state.pdfReaderDefaultSettings) + } + + @Test + fun `reader default settings persist in shared snapshot json`() { + val defaults = ReaderSettings( + fontSize = 21, + lineSpacing = 1.8f, + margin = 72, + readingMode = ReaderReadingMode.VERTICAL, + textAlign = SharedReaderTextAlign.CENTER, + pageWidth = 920, + fontFamily = "Serif", + themeId = "dark", + textureId = "paper", + textureAlpha = 0.25f + ) + + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + SharedLibrarySnapshotJson.encode( + SharedLibrarySnapshot(readerDefaultSettings = defaults) + ) + ) + + assertEquals(defaults, decoded.readerDefaultSettings) + } + + @Test + fun `pdf reader default settings persist separately in shared snapshot json`() { + val epubDefaults = ReaderSettings(themeId = "sepia") + val pdfDefaults = ReaderSettings(themeId = "reverse") + + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + SharedLibrarySnapshotJson.encode( + SharedLibrarySnapshot( + readerDefaultSettings = epubDefaults, + pdfReaderDefaultSettings = pdfDefaults + ) + ) + ) + + assertEquals(epubDefaults, decoded.readerDefaultSettings) + assertEquals(pdfDefaults, decoded.pdfReaderDefaultSettings) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt index e586384..c9f92fa 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt @@ -126,7 +126,7 @@ class ReaderExtrasModelsTest { ) ) val engine = ReaderEngine() - val paginated = engine.createSession(book) + val paginated = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)) .reduce(ReaderAction.GoToChapter(1), engine) val vertical = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)) .reduce(ReaderAction.GoToChapter(1), engine) @@ -194,6 +194,33 @@ class ReaderExtrasModelsTest { assertEquals(listOf(0, 1), ReaderTtsPlanner.chunksFromCurrentLocation(session).map { it.chapterIndex }.distinct()) } + @Test + fun `tts planner starts onward reading at visible locator offset`() { + val source = "First hidden sentence. Second visible sentence. Third visible sentence." + val visibleOffset = source.indexOf("Second") + val book = SharedEpubBook( + id = "tts-visible", + fileName = "tts-visible.epub", + title = "TTS visible", + chapters = listOf(SharedEpubChapter("one", "One", source)) + ) + val session = ReaderEngine().createSession(book).copy( + navigationLocator = ReaderLocator( + chapterIndex = 0, + pageIndex = 0, + startOffset = visibleOffset, + endOffset = visibleOffset, + textQuote = "Second visible sentence." + ) + ) + + val chunks = ReaderTtsPlanner.chunksFromCurrentLocation(session) + + assertEquals(visibleOffset, chunks.first().startOffset) + assertTrue(chunks.first().text.startsWith("Second visible sentence.")) + assertFalse(chunks.any { it.text.startsWith("First hidden") }) + } + @Test fun `tts planner maps trimmed page text back to source offsets`() { val source = "Intro.\n\n Leading words continue." diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SettingsHubModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SettingsHubModelsTest.kt new file mode 100644 index 0000000..e37c796 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SettingsHubModelsTest.kt @@ -0,0 +1,146 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SettingsHubModelsTest { + + @Test + fun `settings hub root shows parent categories only`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP) + ) + + assertEquals( + listOf( + SharedSettingsDestination.EPUB_TEXT, + SharedSettingsDestination.PDF_COMICS, + SharedSettingsDestination.THEME_APPEARANCE, + SharedSettingsDestination.TTS_AI, + SharedSettingsDestination.LIBRARY_SYNC_STORAGE, + SharedSettingsDestination.SYNC_ACCOUNTS, + SharedSettingsDestination.EXTRA + ), + model.rootCategories.map { it.destination } + ) + assertTrue(model.page(SharedSettingsDestination.ROOT).items.isEmpty()) + } + + @Test + fun `offline feature policy hides network backed nested settings`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput( + platform = SharedSettingsPlatform.ANDROID, + featurePolicy = SharedFeaturePolicy.OssOffline, + aiSettingsAvailable = true, + isSignedIn = false + ) + ) + val actions = model.visibleNestedActions() + + assertFalse(SharedSettingsAction.AI_SETTINGS in actions) + assertFalse(SharedSettingsAction.CLOUD_SYNC in actions) + assertFalse(SharedSettingsAction.SIGN_IN in actions) + assertTrue(SharedSettingsAction.TTS_SETTINGS in actions) + assertTrue(SharedSettingsAction.ABOUT in actions) + } + + @Test + fun `sync unavailable hides account rows while preserving folder sync`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput( + platform = SharedSettingsPlatform.DESKTOP, + syncAvailable = false, + folderSyncAvailable = true, + isSignedIn = true, + isProUser = true + ) + ) + val actions = model.visibleNestedActions() + + assertFalse(SharedSettingsAction.SIGN_IN in actions) + assertFalse(SharedSettingsAction.SIGN_OUT in actions) + assertFalse(SharedSettingsAction.CLOUD_SYNC in actions) + assertTrue(SharedSettingsAction.FOLDER_SYNC in actions) + } + + @Test + fun `reader tabs setting can be omitted for platforms without visible tabs`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput( + platform = SharedSettingsPlatform.DESKTOP, + includeReaderTabs = false + ) + ) + + assertFalse(SharedSettingsAction.TABS_TOGGLE in model.visibleNestedActions()) + } + + @Test + fun `local override note appears on reader detail pages only`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP) + ) + + assertFalse( + model.page(SharedSettingsDestination.EPUB_TEXT) + .items + .any { it.action == SharedSettingsAction.LOCAL_OVERRIDE_NOTE } + ) + val note = model.page(SharedSettingsDestination.EPUB_FORMAT).localOverrideNote + + assertEquals(SharedSettingsItemKind.INFO, note?.kind) + assertTrue(note?.summary.orEmpty().contains("Local overrides")) + assertTrue(note?.summary.orEmpty().contains("reader")) + } + + @Test + fun `search returns nested results with breadcrumbs`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP) + ) + + val results = model.searchResults("custom fonts") + + assertEquals(1, results.size) + assertEquals(SharedSettingsAction.CUSTOM_FONTS, results.first().action) + assertEquals("Settings / Library & Files", results.first().breadcrumb) + } + + @Test + fun `settings destinations expose stable parents`() { + assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EPUB_TEXT.parentDestination()) + assertEquals(SharedSettingsDestination.EPUB_TEXT, SharedSettingsDestination.EPUB_FORMAT.parentDestination()) + assertEquals(SharedSettingsDestination.PDF_COMICS, SharedSettingsDestination.PDF_READER_TOOLS.parentDestination()) + assertEquals(SharedSettingsDestination.TTS_AI, SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS.parentDestination()) + assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EXTRA.parentDestination()) + } + + @Test + fun `tts replacements are only exposed from global tts area`() { + val model = sharedSettingsHubModel( + SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP) + ) + + assertFalse( + model.page(SharedSettingsDestination.EPUB_TEXT) + .items + .any { it.action == SharedSettingsAction.TTS_REPLACEMENTS } + ) + assertEquals( + SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS, + model.page(SharedSettingsDestination.TTS_AI) + .items + .single { it.action == SharedSettingsAction.TTS_REPLACEMENTS } + .destination + ) + } +} + +private fun SharedSettingsHubModel.visibleNestedActions(): List { + return rootCategories.flatMap { category -> + page(category.destination).items.map { it.action } + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedImportPlannerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedImportPlannerTest.kt new file mode 100644 index 0000000..3263e61 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedImportPlannerTest.kt @@ -0,0 +1,115 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SharedImportPlannerTest { + + @Test + fun `plan classifies importable duplicate and unsupported files`() { + val plan = SharedImportPlanner.plan( + files = listOf( + ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L), + ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L), + ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 3L), + ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L) + ), + existingBookIds = setOf("/books/existing.epub"), + platform = ReaderPlatform.DESKTOP, + nowMillis = 100L + ) + + assertEquals( + listOf( + SharedImportDecisionStatus.DUPLICATE, + SharedImportDecisionStatus.IMPORTABLE, + SharedImportDecisionStatus.UNSUPPORTED, + SharedImportDecisionStatus.DUPLICATE + ), + plan.decisions.map { it.status } + ) + assertEquals(listOf("/books/new.md"), plan.importedBooks.map { it.id }) + assertEquals(listOf("existing.epub", "new.md", "new.md"), plan.supportedFiles.map { it.name }) + assertEquals(FileType.MD, plan.importedBooks.single().type) + assertEquals(101L, plan.importedBooks.single().timestamp) + assertEquals(null, plan.importedBooks.single().sourceFolder) + assertFalse(plan.importedBooks.single().isRecent) + assertEquals(1, plan.importedCount) + assertEquals(2, plan.duplicateCount) + assertEquals(1, plan.unsupportedCount) + } + + @Test + fun `plan uses uri as stable id when local path is absent`() { + val plan = SharedImportPlanner.plan( + files = listOf( + ImportedBookFile(name = "scan.pdf", uriString = "content://scan", localPath = null, size = 4L, sourceFolder = "content://folder") + ), + existingBookIds = emptySet(), + platform = ReaderPlatform.ANDROID, + nowMillis = 5L + ) + + val book = plan.importedBooks.single() + assertEquals("content://scan", book.id) + assertEquals("content://scan", book.path) + assertEquals("content://folder", book.sourceFolder) + } + + @Test + fun `plan prefers prepared file id over storage path`() { + val plan = SharedImportPlanner.plan( + files = listOf( + ImportedBookFile( + name = "novel.epub", + uriString = null, + localPath = "/app/books/copied.epub", + size = 4L, + id = "content-sha" + ) + ), + existingBookIds = emptySet(), + platform = ReaderPlatform.DESKTOP, + nowMillis = 5L + ) + + val book = plan.importedBooks.single() + assertEquals("content-sha", book.id) + assertEquals("/app/books/copied.epub", book.path) + assertEquals(null, book.sourceFolder) + } + + @Test + fun `feedback prefers imported duplicate unsupported then failed outcomes`() { + val imported = SharedImportPlanner.feedbackForCounts( + counts = SharedImportOutcomeCounts(addedCount = 2, duplicateCount = 1, unsupportedCount = 1), + importedMessage = "imported", + duplicateMessage = "duplicate", + unsupportedMessage = "unsupported", + failedMessage = "failed" + ) + val duplicate = SharedImportPlanner.feedbackForCounts( + counts = SharedImportOutcomeCounts(duplicateCount = 1), + importedMessage = "imported", + duplicateMessage = "duplicate", + unsupportedMessage = "unsupported", + failedMessage = "failed" + ) + val unsupported = SharedImportPlanner.feedbackForCounts( + counts = SharedImportOutcomeCounts(unsupportedCount = 1), + importedMessage = "imported", + duplicateMessage = "duplicate", + unsupportedMessage = "unsupported", + failedMessage = "failed" + ) + + assertEquals("imported", imported.message) + assertFalse(imported.isError) + assertEquals("duplicate", duplicate.message) + assertFalse(duplicate.isError) + assertEquals("unsupported", unsupported.message) + assertTrue(unsupported.isError) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt index d8cd77e..908142e 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt @@ -80,14 +80,19 @@ class SharedLibraryProjectorTest { ) ) - assertEquals(listOf("C:/books/notes.md", "mystery.bin", "C:/books/existing.pdf"), result.books.ids()) + assertEquals(listOf("C:/books/notes.md", "C:/books/existing.pdf"), result.books.ids()) assertEquals(FileType.MD, result.books[0].type) assertEquals("C:/books", result.books[0].sourceFolder) assertFalse(result.books[0].isRecent) - assertEquals(FileType.UNKNOWN, result.books[1].type) - assertFalse(result.books[1].isRecent) assertTrue(projector.home(result).recentBooks.isEmpty()) - assertEquals("Imported 2 file(s). Reader support comes later.", result.message) + assertEquals("Imported 1 file(s). Reader support comes later.", result.message) + + val unsupportedOnly = projector.withImportedFiles( + state, + listOf(ImportedFile(name = "mystery.bin", path = null, size = 3L)) + ) + assertEquals(state.books.ids(), unsupportedOnly.books.ids()) + assertEquals("No supported files were imported.", unsupportedOnly.message) } @Test @@ -199,6 +204,34 @@ class SharedLibraryProjectorTest { assertEquals(listOf("loose", "tagged"), result.shelves.first { it.id == "unshelved" }.books.ids()) } + @Test + fun `SharedLibraryStateProjector auto creates synced folder fallback shelves from source folders`() { + val folderBook = book( + id = "folder_book", + sourceFolder = "C:/Library", + path = "C:/Library/Nested/Book.epub" + ) + + val result = SharedLibraryStateProjector( + SharedFolderPathResolver { item -> + if (item.id == "folder_book") listOf("Nested") else emptyList() + } + ).project( + SharedLibraryProjectionInput( + state = SharedReaderScreenState(), + booksFromStore = listOf(folderBook), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + tags = emptyList() + ) + ) + + assertEquals(listOf("C:/Library"), result.syncedFolders.map { it.uriString }) + assertEquals("Library", result.syncedFolders.single().name) + assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library" }.books.ids()) + assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library::Nested" }.directBooks.ids()) + } + @Test fun `SharedLibraryStateProjector builds smart shelves from shared rules`() { val smartRules = SmartCollectionEngine.toJson( @@ -244,6 +277,10 @@ class SharedLibraryProjectorTest { listOf(ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L)), now = 20L ) + val unsupportedOnly = imported.withImportedFiles( + listOf(ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 2L)), + now = 30L + ) assertEquals(listOf("content://new", "/books/existing.epub"), imported.rawLibraryBooks.ids()) assertEquals(FileType.PDF, imported.rawLibraryBooks.first().type) @@ -262,6 +299,8 @@ class SharedLibraryProjectorTest { assertTrue(projected.recentBooks.isEmpty()) assertEquals("Imported 1 file(s).", imported.bannerMessage?.message) assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message) + assertEquals(imported.rawLibraryBooks.ids(), unsupportedOnly.rawLibraryBooks.ids()) + assertEquals("No supported files were imported.", unsupportedOnly.bannerMessage?.message) } @Test diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt index b1b9cce..821d49d 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt @@ -1,7 +1,9 @@ package com.aryan.reader.shared import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderPageSpreadMode import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.SharedReaderTextAlign @@ -26,14 +28,29 @@ class SharedLibrarySnapshotJsonTest { coverImagePath = "C:/Covers/book.png", title = "Book", author = "Ada", + description = "

A compact shared summary.

", + originalTitle = "Original Book", + originalAuthor = "Original Ada", + originalSeriesName = "Original Series", + originalSeriesIndex = 1.0, + originalDescription = "Original summary", progressPercentage = 42f, fileSize = 99L, + fileContentModifiedTimestamp = 123_456L, sourceFolder = "C:/Books", folderTextMetadataParsed = true, seriesName = "Series", seriesIndex = 2.0, tags = listOf(tag), lastPageIndex = 4, + readerPosition = ReaderLocator( + chapterIndex = 1, + pageIndex = 4, + startOffset = 220, + endOffset = 220, + textQuote = "Precise place", + cfi = "desktop:1:220:220" + ), readerSettings = ReaderSettings( fontSize = 22, lineSpacing = 1.7f, @@ -56,6 +73,9 @@ class SharedLibrarySnapshotJsonTest { systemUiMode = SystemUiMode.HIDDEN, pageInfoMode = PageInfoMode.SYNC, pageInfoPosition = PageInfoPosition.TOP, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE, + pdfVerticalPageGapVisible = false, + pdfPageNumberOverlayVisible = false, seamlessChapterNavigation = false, chapterTurnDragMultiplier = 1.6f ), @@ -91,6 +111,15 @@ class SharedLibrarySnapshotJsonTest { cfi = "desktop:0:128:144" ) ) + ), + pdfReaderViewport = SharedPdfReaderViewport( + pageIndex = 4, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + zoom = 1.8f, + horizontalScrollOffset = 90, + paginatedVerticalScrollOffset = 140, + verticalFirstPageIndex = 3, + verticalFirstPageScrollOffset = 44 ) ) ), @@ -123,6 +152,8 @@ class SharedLibrarySnapshotJsonTest { customAppThemes = listOf( CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C)) ), + readerDefaultSettings = ReaderSettings(themeId = "sepia"), + pdfReaderDefaultSettings = ReaderSettings(themeId = "reverse"), readerToolbarPreferences = ReaderToolbarPreferences( hiddenToolIds = setOf(ReaderTool.SEARCH.id), toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME, ReaderTool.SEARCH), @@ -169,6 +200,55 @@ class SharedLibrarySnapshotJsonTest { assertTrue(decoded.books.isEmpty()) } + @Test + fun `missing tab setting defaults to enabled for new desktop snapshots`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"schemaVersion":14}""") + + assertTrue(decoded.isTabsEnabled) + } + + @Test + fun `legacy untouched epub default settings migrate to vertical mode`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + """ + { + "schemaVersion": 16, + "readerDefaultSettings": { + "readingMode": "PAGINATED" + } + } + """.trimIndent() + ) + + assertEquals(ReaderReadingMode.VERTICAL, decoded.readerDefaultSettings.readingMode) + } + + @Test + fun `legacy reader settings default pdf visual options to current behavior`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + """ + { + "books": [ + { + "id": "book", + "path": "C:/Books/book.pdf", + "type": "PDF", + "displayName": "book.pdf", + "timestamp": 10, + "readerSettings": { + "themeId": "no_theme" + } + } + ] + } + """.trimIndent() + ) + val settings = decoded.books.single().readerSettings ?: error("Expected settings") + + assertTrue(settings.pdfVerticalPageGapVisible) + assertTrue(settings.pdfPageNumberOverlayVisible) + } + @Test fun `legacy snapshot hides imported only books from recent home`() { val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( @@ -201,4 +281,36 @@ class SharedLibrarySnapshotJsonTest { assertFalse(decoded.books.first { it.id == "imported" }.isRecent) assertTrue(decoded.books.first { it.id == "opened" }.isRecent) } + + @Test + fun `synced folder allowed types exclude unknown while preserving valid selections`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + """ + { + "syncedFolders": [ + { + "uriString": "C:/Books", + "name": "Books", + "lastScanTime": 12, + "allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"] + } + ] + } + """.trimIndent() + ) + val folder = decoded.syncedFolders.single() + + assertEquals(setOf(FileType.PDF, FileType.EPUB), folder.allowedFileTypes) + assertFalse(FileType.UNKNOWN in folder.allowedFileTypes) + + val encoded = SharedLibrarySnapshotJson.encode( + SharedLibrarySnapshot( + syncedFolders = listOf( + SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.PDF, FileType.UNKNOWN)) + ) + ) + ) + + assertFalse("\"UNKNOWN\"" in encoded) + } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedReducersTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedReducersTest.kt new file mode 100644 index 0000000..2cbcc2e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedReducersTest.kt @@ -0,0 +1,33 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedReducersTest { + + @Test + fun `book selection can be replaced in one reducer action`() { + val state = SharedReaderScreenState(selectedBookIds = setOf("old")) + + val result = state.reduce(LibraryAction.BookSelectionReplaced(setOf("one", "two"))) + + assertEquals(setOf("one", "two"), result.selectedBookIds) + } + + @Test + fun `visible selection helper selects visible books and clears when all are selected`() { + val visibleBooks = listOf( + BookItem("one", "/books/one.epub", FileType.EPUB, "one.epub", timestamp = 1L), + BookItem("two", "/books/two.epub", FileType.EPUB, "two.epub", timestamp = 2L) + ) + + val selected = SharedReaderScreenState() + .replaceBookSelectionWithVisibleBooks(visibleBooks) + + assertEquals(setOf("one", "two"), selected.selectedBookIds) + assertEquals( + emptySet(), + selected.replaceBookSelectionWithVisibleBooks(visibleBooks).selectedBookIds + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt index 22ead5f..8a062a0 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt @@ -103,5 +103,16 @@ class SharedOpdsCatalogsTest { urlPathSegment = null ) ) + assertEquals( + ".pptx", + SharedOpdsDownloadNamer.resolveExtension( + acquisition = OpdsAcquisition( + "https://example.org/download", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + contentDisposition = null, + urlPathSegment = null + ) + ) } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt index 9b0d4ad..caca3be 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt @@ -1,5 +1,6 @@ package com.aryan.reader.shared.pdf +import androidx.compose.ui.unit.dp import com.aryan.reader.shared.PdfDisplayMode import com.aryan.reader.shared.SearchHighlightMode import kotlin.test.Test @@ -18,6 +19,14 @@ class PdfReaderSessionTest { assertTrue(state.canGoPrevious) } + @Test + fun `initial interaction mode is neutral`() { + val state = SharedPdfReaderState.initial(pageCount = 1) + + assertEquals(PdfInkTool.NONE, state.selectedTool) + assertEquals(false, state.isTextSelectionMode) + } + @Test fun `page navigation clamps to document bounds`() { val state = SharedPdfReaderState.initial(pageCount = 3, initialPageIndex = 1) @@ -61,6 +70,30 @@ class PdfReaderSessionTest { assertEquals(4f, state.zoom) } + @Test + fun `reader viewport clamps zoom pages and scroll offsets`() { + val viewport = SharedPdfReaderViewport( + pageIndex = 99, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + zoom = Float.NaN, + horizontalScrollOffset = -10, + paginatedVerticalScrollOffset = -20, + verticalFirstPageIndex = 40, + verticalFirstPageScrollOffset = -30 + ).sanitized( + pageCount = 5, + zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1.25f) + ) + + assertEquals(PdfDisplayMode.VERTICAL_SCROLL, viewport.displayMode) + assertEquals(4, viewport.pageIndex) + assertEquals(4, viewport.verticalFirstPageIndex) + assertEquals(1.25f, viewport.zoom) + assertEquals(0, viewport.horizontalScrollOffset) + assertEquals(0, viewport.paginatedVerticalScrollOffset) + assertEquals(0, viewport.verticalFirstPageScrollOffset) + } + @Test fun `search query resets active result and result navigation wraps`() { val results = listOf( @@ -68,16 +101,42 @@ class PdfReaderSessionTest { SharedPdfSearchResult(pageIndex = 3, preview = "second", matchIndex = 7) ) - val state = SharedPdfReaderState.initial(pageCount = 5) + val changed = SharedPdfReaderState.initial(pageCount = 5) .reduce(SharedPdfReaderAction.GoToSearchResult(0, results)) + .reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED)) .reduce(SharedPdfReaderAction.SearchChanged("needle")) + val state = changed .reduce(SharedPdfReaderAction.GoToSearchResult(-1, results)) - assertEquals("needle", state.searchQuery) + assertEquals("needle", changed.searchQuery) + assertEquals(-1, changed.activeSearchResultIndex) + assertEquals(SearchHighlightMode.FOCUSED, changed.searchHighlightMode) + assertEquals(1, changed.pageIndex) assertEquals(1, state.activeSearchResultIndex) assertEquals(3, state.pageIndex) } + @Test + fun `search chrome actions open toggle and close shared state`() { + val opened = SharedPdfReaderState.initial(pageCount = 4) + .reduce(SharedPdfReaderAction.SearchOpened) + val typed = opened.reduce(SharedPdfReaderAction.SearchChanged("alpha")) + val hidden = typed.reduce(SharedPdfReaderAction.SearchResultsPanelToggled) + val closed = hidden.reduce(SharedPdfReaderAction.SearchClosed) + + assertTrue(opened.isSearchActive) + assertTrue(opened.showSearchResultsPanel) + assertEquals("alpha", typed.searchQuery) + assertTrue(typed.isSearchActive) + assertTrue(typed.showSearchResultsPanel) + assertEquals(-1, typed.activeSearchResultIndex) + assertEquals(false, hidden.showSearchResultsPanel) + assertEquals(false, closed.isSearchActive) + assertTrue(closed.showSearchResultsPanel) + assertEquals("", closed.searchQuery) + assertEquals(-1, closed.activeSearchResultIndex) + } + @Test fun `search highlight mode toggles between all and focused`() { val focused = SharedPdfReaderState.initial(pageCount = 1) @@ -101,6 +160,22 @@ class PdfReaderSessionTest { assertEquals(config.strokeWidth, state.strokeWidth) } + @Test + fun `text selection markup tools and neutral mode are exclusive`() { + val selectingText = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN)) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + val addingTextAnnotation = selectingText.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.TEXT)) + val neutral = addingTextAnnotation.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) + + assertEquals(true, selectingText.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, selectingText.selectedTool) + assertEquals(false, addingTextAnnotation.isTextSelectionMode) + assertEquals(PdfInkTool.TEXT, addingTextAnnotation.selectedTool) + assertEquals(false, neutral.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, neutral.selectedTool) + } + @Test fun `annotation actions mutate immutable annotation list`() { val first = annotation("first", pageIndex = 0) @@ -236,6 +311,7 @@ class PdfReaderSessionTest { assertEquals(0, punctuationResults.single().matchIndex) assertEquals("hello,\nworld".length, punctuationResults.single().matchLength) assertEquals(listOf(0, 2), alphaResults.map { it.pageIndex }) + assertEquals(listOf(3, 3), alphaResults.map { it.matchLength }) } @Test @@ -296,6 +372,38 @@ class PdfReaderSessionTest { assertEquals(5, pageIndex) } + @Test + fun `vertical page gap option keeps default spacing or removes it`() { + assertEquals(8.dp, pdfVerticalPageGapDp(isPageGapVisible = true, defaultGap = 8.dp)) + assertEquals(0.dp, pdfVerticalPageGapDp(isPageGapVisible = false, defaultGap = 8.dp)) + } + + @Test + fun `vertical page layout removes fractional pixel seams when gap is hidden`() { + val layout = calculatePdfVerticalPageLayoutPx( + pageAspectRatios = listOf(0.707f, 0.721f, 0.69f), + viewportWidthPx = 1081, + viewportHeightPx = 1920, + pageGapPx = 0 + ) + + layout.pages.zipWithNext().forEach { (previous, next) -> + assertEquals(previous.bottomPx, next.topPx) + } + } + + @Test + fun `vertical page layout keeps exact configured page gap`() { + val layout = calculatePdfVerticalPageLayoutPx( + pageAspectRatios = listOf(0.707f, 0.721f), + viewportWidthPx = 1081, + viewportHeightPx = 1920, + pageGapPx = 12 + ) + + assertEquals(layout.pages.first().bottomPx + 12, layout.pages.last().topPx) + } + private fun annotation(id: String, pageIndex: Int): SharedPdfAnnotation { return SharedPdfAnnotation( id = id, diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt index 1074fea..c5cd273 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt @@ -41,10 +41,12 @@ class SharedPdfAnnotationSerializerTest { "ink": [ { "pageIndex": 1, + "id": "ink-1", "annotationType": "INK", "inkType": "PENCIL", "color": -16777216, "strokeWidth": 0.008, + "note": "Desktop-only ink note", "points": [{"x":0.1,"y":0.2,"t":10},{"x":0.3,"y":0.4,"t":12}] } ], @@ -81,8 +83,11 @@ class SharedPdfAnnotationSerializerTest { assertNotNull(data[SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS]) assertEquals(listOf(PdfAnnotationKind.INK, PdfAnnotationKind.TEXT, PdfAnnotationKind.HIGHLIGHT), annotations.map { it.kind }) + assertEquals("ink-1", annotations[0].id) assertEquals(PdfInkTool.PENCIL, annotations[0].tool) + assertEquals("Desktop-only ink note", annotations[0].note) assertEquals(16f, annotations[1].fontSize, 0.001f) + assertEquals(0.032f, annotations[1].pageRelativeFontSize ?: 0f, 0.0001f) assertTrue(annotations[1].isBold) assertEquals("Keep this", annotations[2].note) assertEquals(4, annotations[2].rangeStartIndex) @@ -110,7 +115,8 @@ class SharedPdfAnnotationSerializerTest { text = "Desktop text", colorArgb = 0xFF112233.toInt(), backgroundArgb = 0x66112233, - fontSize = 20f + fontSize = 20f, + pageRelativeFontSize = 0.031f ), SharedPdfAnnotation( id = "highlight-1", @@ -141,15 +147,72 @@ class SharedPdfAnnotationSerializerTest { assertEquals("FOUNTAIN_PEN", legacy.getValue("ink").jsonArray[0].jsonObject.getValue("inkType").jsonPrimitive.content) assertEquals(1, legacy.getValue("textBoxes").jsonArray.size) assertEquals( - 0.04, + 0.031, legacy.getValue("textBoxes").jsonArray[0].jsonObject.getValue("fontSize").jsonPrimitive.content.toDouble(), 0.0001 ) assertEquals(1, legacy.getValue("highlights").jsonArray.size) + assertEquals("BLUE", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("color").jsonPrimitive.content) assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content) assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt()) } + @Test + fun `sidecar codec treats canonical annotations as authoritative for android legacy expansion`() { + val canonicalAnnotation = SharedPdfAnnotation( + id = "desktop-ink", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = PdfInkTool.PEN, + points = listOf(PdfPagePoint(0.1f, 0.2f, 100L)), + note = "Edited on desktop", + colorArgb = 0xFF112233.toInt(), + strokeWidth = 0.01f + ) + val payload = testJson.encodeToString( + JsonElement.serializer(), + JsonObject( + mapOf( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to + SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(canonicalAnnotation)), + "ink" to testJson.parseToJsonElement( + """[{"id":"stale","pageIndex":9,"annotationType":"INK","inkType":"PENCIL","color":0,"strokeWidth":1,"points":[{"x":0.9,"y":0.9}]}]""" + ) + ) + ) + ) + + val legacy = testJson.parseToJsonElement( + SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload) + ).jsonObject + val ink = legacy.getValue("ink").jsonArray.single().jsonObject + + assertEquals("desktop-ink", ink.getValue("id").jsonPrimitive.content) + assertEquals("Edited on desktop", ink.getValue("note").jsonPrimitive.content) + assertEquals(0, ink.getValue("pageIndex").jsonPrimitive.content.toInt()) + } + + @Test + fun `sidecar codec expands empty canonical annotations to empty android legacy arrays`() { + val payload = testJson.encodeToString( + JsonElement.serializer(), + JsonObject( + mapOf( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to + SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(emptyList()) + ) + ) + ) + + val legacy = testJson.parseToJsonElement( + SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload) + ).jsonObject + + assertEquals(0, legacy.getValue("ink").jsonArray.size) + assertEquals(0, legacy.getValue("textBoxes").jsonArray.size) + assertEquals(0, legacy.getValue("highlights").jsonArray.size) + } + @Test fun `embedded annotation threads link replies and nearby orphan comments`() { val root = embeddedAnnotation( diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt index 342c882..4610be0 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt @@ -139,6 +139,35 @@ class SharedPdfRichTextTest { assertEquals(document, decoded) } + @Test + fun `loaded rich text rescales font spans when real page height arrives`() { + val document = SharedPdfRichDocument( + text = "Stable size", + spans = listOf( + SharedPdfRichSpan( + start = 0, + end = 6, + color = Color.Black.toArgb(), + backgroundColor = Color.Transparent.toArgb(), + fontSizeNorm = 0.02f, + isBold = false, + isItalic = false, + isUnderline = false, + isStrikethrough = false + ) + ) + ) + val referenceHeight = 1_414f + val actualHeight = 1_000f + val loadedBeforeLayout = SharedPdfRichTextMapper.toAnnotatedString(document, referenceHeight) + + val loadedAtActualHeight = loadedBeforeLayout.withScaledSharedPdfRichFontSizes(actualHeight / referenceHeight) + val savedAgain = SharedPdfRichTextMapper.fromAnnotatedString(loadedAtActualHeight, actualHeight) + + assertEquals(20.sp, loadedAtActualHeight.spanStyles.single().item.fontSize) + assertEquals(0.02f, savedAgain.spans.single().fontSizeNorm, 0.0001f) + } + @Test fun `serializer returns empty document for blank and corrupt payloads`() { assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode("")) diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt index 361cb55..e763551 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt @@ -35,7 +35,8 @@ class SharedPdfTextAnnotationsTest { assertEquals(PdfAnnotationKind.TEXT, annotation.kind) assertEquals(PdfInkTool.TEXT, annotation.tool) assertEquals("Styled note", annotation.text) - assertEquals(style, annotation.sharedPdfTextStyle()) + assertEquals(style.copy(pageRelativeFontSize = 0.04f), annotation.sharedPdfTextStyle()) + assertEquals(0.04f, annotation.pageRelativeFontSize ?: 0f, 0.0001f) assertEquals(99L, annotation.createdAt) assertTrue(annotation.bounds!!.left >= 0f) assertTrue(annotation.bounds.right <= 1f) @@ -72,7 +73,28 @@ class SharedPdfTextAnnotationsTest { assertEquals("Keep me", updated.text) assertEquals(original.bounds, updated.bounds) assertEquals(5L, updated.createdAt) - assertEquals(style, updated.sharedPdfTextStyle()) + assertEquals(style.copy(pageRelativeFontSize = 0.048f), updated.sharedPdfTextStyle()) + } + + @Test + fun `page relative font size drives Android-compatible text rendering size`() { + val canvasSize = IntSize(1_000, 1_500) + val style = SharedPdfTextStyleConfig(fontSize = 20f, pageRelativeFontSize = 0.03f) + + assertEquals(45f, style.sharedPdfTextFontSizePx(canvasSize), 0.0001f) + + val annotation = SharedPdfAnnotation( + id = "text-android-size", + pageIndex = 0, + kind = PdfAnnotationKind.TEXT, + bounds = PdfPageBounds(0.1f, 0.1f, 0.4f, 0.2f), + text = "Sized like Android", + colorArgb = 0xFF000000.toInt(), + fontSize = 20f, + pageRelativeFontSize = 0.03f + ) + + assertEquals(45f, annotation.sharedPdfTextFontSizePx(canvasSize), 0.0001f) } @Test @@ -118,7 +140,7 @@ class SharedPdfTextAnnotationsTest { assertEquals(PdfAnnotationKind.TEXT, annotation.kind) assertEquals(PdfInkTool.TEXT, annotation.tool) assertEquals("Inline note", annotation.text) - assertEquals(style, annotation.sharedPdfTextStyle()) + assertEquals(style.copy(pageRelativeFontSize = 0.036f), annotation.sharedPdfTextStyle()) assertEquals(draft.bounds, annotation.bounds) } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt index 19b660f..60b36f6 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt @@ -1,5 +1,6 @@ package com.aryan.reader.shared.reader +import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.paginatedreader.CssStyle import com.aryan.reader.paginatedreader.SemanticParagraph import kotlin.test.Test @@ -37,6 +38,111 @@ class ReaderEngineTest { assertSame(first.reader.pages, second.reader.pages) } + @Test + fun `visual settings update does not repaginate or move current page`() { + val engine = ReaderEngine() + val session = engine.goToPage(engine.createSession(longBook()), 1) + val oldPages = session.reader.pages + val oldPageIndex = session.reader.currentPageIndex + + val updated = engine.updateSettings( + session, + session.reader.settings.copy( + darkMode = true, + themeId = "night", + backgroundColorArgb = 0xFF101010L, + textColorArgb = 0xFFEFEFEFL, + textureId = "paper", + textureAlpha = 0.25f + ) + ) + + assertSame(oldPages, updated.reader.pages) + assertEquals(oldPageIndex, updated.reader.currentPageIndex) + assertEquals("night", updated.reader.settings.themeId) + } + + @Test + fun `createSession restores precise locator ahead of fallback page index`() { + val engine = ReaderEngine() + val book = longBook() + val base = engine.createSession(book) + val targetPage = base.reader.pages.getOrNull(2) ?: error("Expected multiple pages") + val locator = ReaderLocator( + chapterIndex = targetPage.chapterIndex, + pageIndex = targetPage.pageIndex, + startOffset = targetPage.startOffset + 12, + endOffset = targetPage.startOffset + 12, + cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 12}:${targetPage.startOffset + 12}" + ) + + val restored = engine.createSession( + book = book, + initialPageIndex = 0, + initialLocator = locator + ) + + assertEquals(targetPage.pageIndex, restored.navigationLocator?.pageIndex) + assertEquals(targetPage.pageIndex, restored.reader.currentPageIndex) + assertEquals(locator.startOffset, restored.navigationLocator?.startOffset) + } + + @Test + fun `layout settings update keeps precise visible locator across reading modes`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook()) + val targetPage = session.reader.pages.getOrNull(1) ?: error("Expected multiple pages") + val visibleLocator = ReaderLocator( + chapterIndex = targetPage.chapterIndex, + pageIndex = targetPage.pageIndex, + startOffset = targetPage.startOffset + 40, + endOffset = targetPage.startOffset + 40, + textQuote = "visible text", + cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 40}:${targetPage.startOffset + 40}" + ) + val synced = engine.syncVisiblePage(session, targetPage.pageIndex, visibleLocator) + + val updated = engine.updateSettings( + synced, + synced.reader.settings.copy( + readingMode = ReaderReadingMode.VERTICAL, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE, + fontSize = synced.reader.settings.fontSize + 4 + ) + ) + + val page = updated.reader.currentPage ?: error("Expected current page") + assertEquals(visibleLocator.startOffset, updated.navigationLocator?.startOffset) + assertTrue(visibleLocator.startOffset!! in page.startOffset..page.endOffset) + } + + @Test + fun `two page spread keeps right page locator while normalizing visible spread start`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook()) + val targetPage = session.reader.pages.getOrNull(3) ?: error("Expected multiple pages") + val locator = ReaderLocator( + chapterIndex = targetPage.chapterIndex, + pageIndex = targetPage.pageIndex, + startOffset = targetPage.startOffset + 20, + endOffset = targetPage.startOffset + 20, + cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 20}:${targetPage.startOffset + 20}" + ) + val synced = engine.syncVisiblePage(session, targetPage.pageIndex, locator) + + val updated = engine.updateSettings( + synced, + synced.reader.settings.copy( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + ) + + assertEquals(targetPage.pageIndex - 1, updated.reader.currentPageIndex) + assertEquals(targetPage.pageIndex, updated.navigationLocator?.pageIndex) + assertEquals(locator.startOffset, updated.navigationLocator?.startOffset) + } + @Test fun `search returns every match on a page`() { val engine = ReaderEngine() @@ -60,6 +166,7 @@ class ReaderEngineTest { assertEquals(3, searched.searchResults.size) assertEquals(listOf(0, 11, 23), searched.searchResults.map { it.matchIndex }) assertTrue(searched.searchResults.all { it.pageIndex == 0 }) + assertEquals(-1, searched.activeSearchResultIndex) val secondMatch = engine.goToSearchResult(searched, 1) @@ -172,6 +279,116 @@ class ReaderEngineTest { assertEquals(7, target.locator.startOffset) } + @Test + fun `jump navigation records locator history and can step back and forward`() { + val engine = ReaderEngine() + val session = engine.createSession(multiChapterBook()) + + val second = engine.jumpToChapter(session, 1) + val third = engine.jumpToChapter(second, 2) + val back = engine.jumpBack(third) + val forward = engine.jumpForward(back) + + assertEquals(1, third.jumpHistory.backLocator?.chapterIndex) + assertEquals(1, back.reader.currentPage?.chapterIndex) + assertEquals(0, back.jumpHistory.backLocator?.chapterIndex) + assertEquals(2, back.jumpHistory.forwardLocator?.chapterIndex) + assertEquals(2, forward.reader.currentPage?.chapterIndex) + assertTrue(engine.clearJumpHistory(forward).jumpHistory.locators.isEmpty()) + } + + @Test + fun `paginated mode does not record or use jump history`() { + val engine = ReaderEngine() + val session = engine.createSession( + book = multiChapterBook(), + settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED) + ) + + val jumped = engine.jumpToChapter(session, 1) + val verticalWithHistory = engine.jumpToChapter(engine.createSession(multiChapterBook()), 1) + val switchedToPaginated = engine.updateSettings( + verticalWithHistory, + verticalWithHistory.reader.settings.copy(readingMode = ReaderReadingMode.PAGINATED) + ) + val withLegacyHistory = jumped.copy( + jumpHistory = ReaderJumpHistory() + .record( + currentLocator = ReaderLocator(chapterIndex = 0, cfi = "desktop:0:0:0"), + targetLocator = ReaderLocator(chapterIndex = 1, cfi = "desktop:1:0:0"), + chapterCount = 3 + ) + ) + val back = engine.jumpBack(withLegacyHistory) + + assertTrue(jumped.jumpHistory.locators.isEmpty()) + assertTrue(switchedToPaginated.jumpHistory.locators.isEmpty()) + assertEquals(jumped.reader.currentPageIndex, back.reader.currentPageIndex) + assertTrue(back.jumpHistory.locators.isEmpty()) + } + + @Test + fun `replacePages uses captured reflow anchor when no newer navigation happened`() { + val engine = ReaderEngine() + val book = manualRangeBook() + val oldPages = listOf( + ReaderPage(0, 0, "One", "first", 0, 100), + ReaderPage(1, 0, "One", "second", 100, 200) + ) + val newPages = listOf( + ReaderPage(0, 0, "One", "first expanded", 0, 140), + ReaderPage(1, 0, "One", "second shifted", 140, 260) + ) + val session = engine.createSession(book).copy( + reader = PaginatedReaderState(book, oldPages, currentPageIndex = 1), + navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20), + navigationRequestId = 4L + ) + val reflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160) + + val replaced = engine.replacePages( + state = session, + pages = newPages, + reflowAnchor = reflowAnchor, + navigationRequestIdAtReflowStart = 4L + ) + + assertEquals(1, replaced.reader.currentPageIndex) + assertEquals(1, replaced.navigationLocator?.pageIndex) + assertEquals(160, replaced.navigationLocator?.startOffset) + } + + @Test + fun `replacePages lets newer explicit navigation override reflow anchor`() { + val engine = ReaderEngine() + val book = manualRangeBook() + val oldPages = listOf( + ReaderPage(0, 0, "One", "first", 0, 100), + ReaderPage(1, 0, "One", "second", 100, 200) + ) + val newPages = listOf( + ReaderPage(0, 0, "One", "first expanded", 0, 140), + ReaderPage(1, 0, "One", "second shifted", 140, 260) + ) + val session = engine.createSession(book).copy( + reader = PaginatedReaderState(book, oldPages, currentPageIndex = 0), + navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20), + navigationRequestId = 5L + ) + val staleReflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160) + + val replaced = engine.replacePages( + state = session, + pages = newPages, + reflowAnchor = staleReflowAnchor, + navigationRequestIdAtReflowStart = 4L + ) + + assertEquals(0, replaced.reader.currentPageIndex) + assertEquals(0, replaced.navigationLocator?.pageIndex) + assertEquals(20, replaced.navigationLocator?.startOffset) + } + private fun longBook(): SharedEpubBook { return SharedEpubBook( id = "long", @@ -187,4 +404,32 @@ class ReaderEngineTest { ) ) } + + private fun multiChapterBook(): SharedEpubBook { + return SharedEpubBook( + id = "multi", + fileName = "multi.epub", + title = "Multi", + chapters = listOf( + SharedEpubChapter(id = "one", title = "One", plainText = "First chapter text."), + SharedEpubChapter(id = "two", title = "Two", plainText = "Second chapter text."), + SharedEpubChapter(id = "three", title = "Three", plainText = "Third chapter text.") + ) + ) + } + + private fun manualRangeBook(): SharedEpubBook { + return SharedEpubBook( + id = "manual", + fileName = "manual.epub", + title = "Manual", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = List(300) { "x" }.joinToString("") + ) + ) + ) + } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt index f7145e7..76ad406 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt @@ -61,8 +61,156 @@ class ReaderHtmlDocumentBuilderTest { highlights = listOf(highlight) ) - assertEquals(1, Regex("alpha beta""")) + assertEquals(1, Regex("alpha beta""")) + } + + @Test + fun `stored highlights split around paragraph markup`() { + val text = "alpha\n\nbeta" + val highlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:0:0:${text.length}", + text = "alpha beta", + color = HighlightColor.YELLOW, + chapterIndex = 0, + locator = ReaderLocator( + chapterIndex = 0, + pageIndex = 0, + startOffset = 0, + endOffset = text.length, + textQuote = "alpha beta", + cfi = "desktop:0:0:${text.length}" + ) + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook(text), + page = ReaderPage(0, 0, "One", text, 0, text.length), + settings = ReaderSettings(), + highlights = listOf(highlight) + ) + + assertEquals(2, Regex("")) + } + + @Test + fun `reader highlight script verifies stored text before applying offsets`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta alpha beta"), + page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "alpha beta alpha beta", + startOffset = 0, + endOffset = 21 + ), + settings = ReaderSettings() + ) + + assertTrue(html.contains("actualNormalized !== expectedNormalized")) + assertTrue(html.contains("startOffset >= pageEnd || endOffset <= pageStart")) + assertTrue(html.contains("normalizedRangeForText(searchRoot, expectedNormalized, false)")) + assertTrue(html.contains("locator.textQuote || highlight.text")) + } + + @Test + fun `reader highlight script wraps locally before guarded bridge send`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage(0, 0, "One", "alpha beta", 0, 10), + settings = ReaderSettings() + ) + val localWrapIndex = html.indexOf("wrapRangeTextSegments(localRange") + val bridgeSendIndex = html.indexOf("sendReaderHighlightCreated(payload, 0)") + + assertTrue(html.contains("function sendReaderHighlightCreated(payload, attempt)")) + assertTrue(html.contains("highlight_bridge_error attempt=")) + assertTrue(html.contains("var marker = document.createElement('span');")) + assertTrue(html.contains("range.intersectsNode(node)")) + assertFalse(html.contains("paintUserHighlightRange(payload")) + assertTrue(localWrapIndex >= 0) + assertTrue(bridgeSendIndex > localWrapIndex) + } + + @Test + fun `reader highlight script wraps text fallback highlights without stale overlay rects`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage(0, 0, "One", "alpha beta", 0, 10), + settings = ReaderSettings() + ) + + assertTrue(html.contains("function applyHighlightTextFallback(highlight)")) + assertTrue(html.contains("applyHighlightTextFallback(highlight);")) + assertTrue(html.contains("normalizedRangeForText(content, expectedText, false)")) + assertTrue(html.contains("wrapRangeTextSegments(range, function ()")) + assertFalse(html.contains("function paintUserHighlightRange(")) + assertFalse(html.contains("reader-user-highlight-layer")) + assertFalse(html.contains("reader-user-highlight-rect")) + } + + @Test + fun `reader highlight script rejects mismatched fallback text ranges`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta alpha beta"), + page = ReaderPage(0, 0, "One", "alpha beta alpha beta", 0, 21), + settings = ReaderSettings() + ) + + assertTrue(html.contains("function rangeMatchesStoredOffsets(content, range, startOffset, endOffset)")) + assertTrue(html.contains("rangeMatchesStoredOffsets(content, textRange, startOffset, endOffset)")) + assertTrue(html.contains("highlight_expected_mismatch id=")) + } + + @Test + fun `reader highlight script reconciles unsaved local highlight wrappers`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage(0, 0, "One", "alpha beta", 0, 10), + settings = ReaderSettings() + ) + + assertTrue(html.contains("var readerCurrentHighlights = [];")) + assertTrue(html.contains("function scheduleReaderHighlightReconcile()")) + assertTrue(html.contains("scheduleReaderHighlightReconcile();")) + } + + @Test + fun `page document can render a two page spread`() { + val left = ReaderPage( + pageIndex = 2, + chapterIndex = 0, + chapterTitle = "One", + text = "left page", + startOffset = 0, + endOffset = 9 + ) + val right = ReaderPage( + pageIndex = 3, + chapterIndex = 0, + chapterTitle = "One", + text = "right page", + startOffset = 10, + endOffset = 20 + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("left page\n\nright page"), + page = left, + visiblePages = listOf(left, right), + settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) + ) + + assertTrue(html.contains("reader-spread")) + assertEquals(2, Regex("
reference""")) + assertTrue(html.contains("--reader-link:")) + assertTrue(html.contains("a[href],")) + assertTrue(html.contains("color: var(--reader-link) !important")) + assertTrue(html.contains("a[href] *")) assertTrue(html.contains("readerLinkClicked")) assertTrue(html.contains("bridge_missing")) assertTrue(html.contains("readerlink://click?payload=")) @@ -360,6 +677,34 @@ class ReaderHtmlDocumentBuilderTest { assertTrue(html.contains("""Chapter two""")) } + @Test + fun `vertical document includes theme aware link styling`() { + val html = ReaderHtmlDocumentBuilder.verticalDocument( + book = SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Read more", + htmlContent = """

Read more

""" + ) + ) + ), + settings = ReaderSettings( + readingMode = ReaderReadingMode.VERTICAL, + darkMode = true + ) + ) + + assertTrue(html.contains("--reader-link:")) + assertTrue(html.contains("--reader-link-bg: rgba(")) + assertTrue(html.contains("a[href] *,")) + assertTrue(html.contains("""Read more""")) + } + private fun repeatedWordBook(text: String): SharedEpubBook { return SharedEpubBook( id = "book", diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistoryTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistoryTest.kt new file mode 100644 index 0000000..7e4e114 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderJumpHistoryTest.kt @@ -0,0 +1,72 @@ +package com.aryan.reader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReaderJumpHistoryTest { + + @Test + fun `records explicit locator jumps and exposes back and forward locators`() { + val start = locator(chapter = 0, cfi = "start") + val middle = locator(chapter = 1, cfi = "middle") + val end = locator(chapter = 2, cfi = "end") + + val recorded = ReaderJumpHistory() + .record(currentLocator = start, targetLocator = middle, chapterCount = 4) + .record(currentLocator = middle, targetLocator = end, chapterCount = 4) + + val steppedBack = recorded.stepBack() + val branched = steppedBack.record( + currentLocator = middle, + targetLocator = locator(chapter = 3, cfi = "appendix"), + chapterCount = 4 + ) + + assertEquals(listOf(start, middle, end), recorded.locators) + assertEquals(middle, recorded.backLocator) + assertEquals(null, recorded.forwardLocator) + assertEquals(start, steppedBack.backLocator) + assertEquals(end, steppedBack.forwardLocator) + assertEquals(listOf(start, middle, locator(chapter = 3, cfi = "appendix")), branched.locators) + assertEquals(middle, branched.backLocator) + } + + @Test + fun `ignores invalid and duplicate jumps prunes chapters and caps entries`() { + val unchanged = ReaderJumpHistory() + .record(currentLocator = locator(chapter = 0, cfi = "same"), targetLocator = locator(chapter = 0, cfi = "same"), chapterCount = 3) + .record(currentLocator = locator(chapter = 0, cfi = "ok"), targetLocator = locator(chapter = 99, cfi = "bad"), chapterCount = 3) + + val pruned = ReaderJumpHistory( + locators = listOf( + locator(chapter = 0, cfi = "start"), + locator(chapter = 3, cfi = "drop"), + locator(chapter = 1, cfi = "keep") + ), + cursor = 2 + ).pruned(chapterCount = 2) + + val capped = (0 until 40).fold(ReaderJumpHistory(maxEntries = 5)) { history, index -> + history.record( + currentLocator = locator(chapter = 0, cfi = "spot-$index"), + targetLocator = locator(chapter = 0, cfi = "spot-${index + 1}"), + chapterCount = 1 + ) + } + + assertTrue(unchanged.locators.isEmpty()) + assertTrue( + locator(chapter = 0, cfi = "stable").copy(pageIndex = 12) + .hasSameJumpLocation(locator(chapter = 0, cfi = "stable").copy(pageIndex = 48)) + ) + assertEquals(listOf(locator(chapter = 0, cfi = "start"), locator(chapter = 1, cfi = "keep")), pruned.locators) + assertEquals(1, pruned.cursor) + assertEquals((36..40).map { locator(chapter = 0, cfi = "spot-$it") }, capped.locators) + assertEquals(4, capped.cursor) + } + + private fun locator(chapter: Int, cfi: String): ReaderLocator { + return ReaderLocator(chapterIndex = chapter, cfi = cfi) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderSpreadLayoutTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderSpreadLayoutTest.kt new file mode 100644 index 0000000..cfc3f5d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderSpreadLayoutTest.kt @@ -0,0 +1,78 @@ +package com.aryan.reader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderSpreadLayoutTest { + + @Test + fun `single page mode keeps direct page indexes`() { + val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE) + + assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings)) + assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings)) + assertEquals("4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings)) + } + + @Test + fun `two page mode normalizes direct jumps to the spread start`() { + val settings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals(2, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings)) + assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings)) + assertEquals("3-4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings)) + assertEquals(2, ReaderSpreadLayout.sliderPositionForPage(3, pageCount = 10, settings = settings)) + assertEquals(3, ReaderSpreadLayout.pageNumberForSliderPosition(2, pageCount = 10, settings = settings)) + } + + @Test + fun `two page mode advances by spread and clamps odd final page`() { + val settings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals(3, ReaderSpreadLayout.sliderStepCount(pageCount = 5, settings = settings)) + assertEquals(2, ReaderSpreadLayout.nextPageIndex(0, pageCount = 5, settings = settings)) + assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 5, settings = settings)) + assertEquals(listOf(4), ReaderSpreadLayout.visiblePageIndices(4, pageCount = 5, settings = settings)) + assertEquals(5, ReaderSpreadLayout.pageNumberForSliderPosition(3, pageCount = 5, settings = settings)) + assertFalse(ReaderSpreadLayout.canGoNext(4, pageCount = 5, settings = settings)) + assertTrue(ReaderSpreadLayout.canGoNext(2, pageCount = 5, settings = settings)) + } + + @Test + fun `two page progress uses the visible spread end`() { + val settings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + val state = PaginatedReaderState( + book = SharedEpubBook("book", "book.epub", "Book", chapters = emptyList()), + pages = (0 until 4).map { index -> + ReaderPage(index, chapterIndex = 0, chapterTitle = "One", text = "$index", startOffset = index, endOffset = index + 1) + }, + currentPageIndex = 2, + settings = settings + ) + + assertEquals(100f, state.progress) + } + + @Test + fun `spread mode is ignored in vertical reading`() { + val settings = ReaderSettings( + readingMode = ReaderReadingMode.VERTICAL, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings)) + assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings)) + assertEquals(1, ReaderSpreadLayout.pageStep(settings)) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt index 0889a47..6a9c591 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt @@ -4,6 +4,9 @@ import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.FileType import com.aryan.reader.shared.LibraryFilters import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFeaturePolicy +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfType @@ -16,6 +19,43 @@ import kotlin.test.assertTrue class NonReaderLayoutModelsTest { + @Test + fun `desktop library exposes the same top level organization tabs as Android`() { + val visibleTabs = visibleNonReaderLibraryTabs() + + assertEquals( + listOf( + NonReaderLibraryTab.BOOKS, + NonReaderLibraryTab.SHELVES, + NonReaderLibraryTab.FOLDERS + ), + visibleTabs + ) + assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs) + assertFalse(NonReaderLibraryTab.TAGS in visibleTabs) + assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs) + assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs) + assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs) + } + + @Test + fun `desktop library filter file type groups include every shared readable format`() { + val groupedTypes = nonReaderLibraryFileTypeGroups().flatMap { it.fileTypes } + + assertEquals( + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), + groupedTypes.toSet() + ) + assertEquals(groupedTypes.size, groupedTypes.toSet().size) + assertTrue(FileType.DOCX in groupedTypes) + assertTrue(FileType.FODT in groupedTypes) + assertFalse(FileType.PPTX in groupedTypes) + assertTrue( + nonReaderLibraryFileTypeGroups() + .any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes } + ) + } + @Test fun `home layout separates active tab pinned and recent books`() { val activeTab = book("tab", title = "Open Tab", progress = 12f) @@ -116,6 +156,56 @@ class NonReaderLayoutModelsTest { assertEquals(1, organization.folderCount) } + @Test + fun `library visible selection follows folder shelf navigation`() { + val rootBook = book("root", sourceFolder = "/sync") + val childBook = book("child", sourceFolder = "/sync") + val rootShelf = Shelf( + id = "folder_/sync", + name = "Sync", + type = ShelfType.FOLDER, + books = listOf(rootBook, childBook), + directBooks = listOf(rootBook), + childShelfIds = listOf("folder_/sync::Nested") + ) + val childShelf = Shelf( + id = "folder_/sync::Nested", + name = "Nested", + type = ShelfType.FOLDER, + books = listOf(childBook), + directBooks = listOf(childBook), + parentShelfId = rootShelf.id, + depth = 1 + ) + + val rootState = SharedReaderScreenState( + shelves = listOf(rootShelf, childShelf), + libraryBooks = listOf(rootBook, childBook) + ) + val childState = rootState.copy(viewingShelfId = childShelf.id) + + assertEquals( + listOf("root", "child"), + rootState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id } + ) + assertEquals( + listOf("child"), + childState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id } + ) + } + + @Test + fun `library organization does not expose unknown as an available file type`() { + val organization = SharedReaderScreenState( + rawLibraryBooks = listOf( + book("known", type = FileType.PDF), + book("unknown", type = FileType.UNKNOWN) + ) + ).toNonReaderLibraryOrganizationModel() + + assertEquals(listOf(FileType.PDF), organization.availableFileTypes) + } + @Test fun `shell model keeps primary navigation simple and exposes all tool actions`() { val model = sharedAppShellModel( @@ -124,7 +214,7 @@ class NonReaderLayoutModelsTest { ) assertEquals( - listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.READER), + listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS), model.primaryTabs ) assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab) @@ -138,12 +228,68 @@ class NonReaderLayoutModelsTest { assertTrue(SharedAppToolAction.SUPPORT in model.toolActions) assertTrue(SharedAppToolAction.ABOUT in model.toolActions) assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions) + assertTrue(model.showPrimaryNavigation) val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false) assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab) assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions) } + @Test + fun `shell model hides primary navigation while reading`() { + val readerModel = sharedAppShellModel( + selectedTab = SharedAppTab.READER, + aiSettingsAvailable = true + ) + val libraryModel = sharedAppShellModel( + selectedTab = SharedAppTab.LIBRARY, + aiSettingsAvailable = true + ) + + assertFalse(readerModel.showPrimaryNavigation) + assertTrue(libraryModel.showPrimaryNavigation) + } + + @Test + fun `offline shell model hides network backed navigation and tools`() { + val model = sharedAppShellModel( + selectedTab = SharedAppTab.CATALOGS, + aiSettingsAvailable = true, + featurePolicy = SharedFeaturePolicy.OssOffline + ) + + assertEquals(listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY), model.primaryTabs) + assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab) + assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions) + assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions) + assertFalse(SharedAppToolAction.SUPPORT in model.toolActions) + assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions) + assertTrue(SharedAppToolAction.ABOUT in model.toolActions) + assertTrue(model.showPrimaryNavigation) + } + + @Test + fun `collection cover stack uses Android cover order and limit`() { + val books = listOf( + book("one", coverImagePath = "/covers/one.png"), + book("two", coverImagePath = "/covers/two.png"), + book("three", coverImagePath = "/covers/three.png"), + book("four", coverImagePath = "/covers/four.png"), + book("five", coverImagePath = "/covers/five.png") + ) + + val coverBooks = collectionCoverStackBooks( + Shelf("manual", "Manual", ShelfType.MANUAL, books) + ) + + assertEquals(listOf("four", "three", "two", "one"), coverBooks.map { it.id }) + assertEquals( + listOf("/covers/four.png", "/covers/three.png", "/covers/two.png", "/covers/one.png"), + coverBooks.map { it.coverImagePath } + ) + assertTrue(collectionCoverStackBooks(Shelf("empty", "Empty", ShelfType.FOLDER, emptyList())).isEmpty()) + } + private fun book( id: String, title: String = id, @@ -151,13 +297,15 @@ class NonReaderLayoutModelsTest { progress: Float? = null, tags: List = emptyList(), sourceFolder: String? = null, - path: String? = "/books/$id.epub" + path: String? = "/books/$id.epub", + coverImagePath: String? = null ) = BookItem( id = id, path = path, type = type, displayName = "$id.epub", timestamp = 1L, + coverImagePath = coverImagePath, title = title, progressPercentage = progress, tags = tags, diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt index dbd9186..647a29e 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt @@ -8,7 +8,8 @@ import com.aryan.reader.shared.ReaderTool import com.aryan.reader.shared.ReaderToolbarPreferences import com.aryan.reader.shared.pdf.SharedPdfReaderState import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.SampleReaderBooks +import com.aryan.reader.shared.reader.SharedEpubBook +import com.aryan.reader.shared.reader.SharedEpubChapter import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -18,10 +19,10 @@ import kotlin.test.assertTrue class ReaderWorkspaceModelsTest { @Test - fun `epub workspace maps shared toolbar preferences to reader sidebars and inspector`() { - val session = ReaderEngine().createSession(SampleReaderBooks.desktopWelcomeBook()) + fun `epub workspace maps shared toolbar preferences without toolbar tab`() { + val session = ReaderEngine().createSession(readerFixtureBook()) val preferences = ReaderToolbarPreferences( - hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id), + hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id, ReaderTool.BOOKMARK.id), bottomToolIds = setOf(ReaderTool.SLIDER.id, ReaderTool.SEARCH.id) ) @@ -33,21 +34,32 @@ class ReaderWorkspaceModelsTest { ) assertEquals(ReaderWorkspaceKind.EPUB, model.kind) - assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections) - assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) - assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections) + assertEquals( + listOf( + ReaderWorkspaceLeftSection.CONTENTS, + ReaderWorkspaceLeftSection.NOTES, + ReaderWorkspaceLeftSection.BOOKMARKS + ), + model.leftSections + ) + assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) + assertFalse(ReaderWorkspaceTopAction.BOOKMARK in model.topActions) assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections) assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections) - assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections) + assertFalse(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections) assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions) + assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions) assertTrue(ReaderWorkspaceTopAction.AI in model.topActions) assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions) + assertFalse(model.panelDefaults.leftOpen) + assertFalse(model.panelDefaults.inspectorOpen) + assertFalse(model.chrome.preferAutoHide) } @Test fun `chrome model is forced visible for active reader states`() { val model = readerWorkspaceChromeModel( - preferAutoHide = true, + preferAutoHide = false, searchActive = true, leftPanelOpen = false, inspectorOpen = true, @@ -59,7 +71,7 @@ class ReaderWorkspaceModelsTest { ttsBusy = true ) - assertTrue(model.preferAutoHide) + assertFalse(model.preferAutoHide) assertTrue(model.forceVisible) assertEquals( setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"), @@ -67,6 +79,48 @@ class ReaderWorkspaceModelsTest { ) } + @Test + fun `epub workspace ignores desktop visual options in inspector`() { + val session = ReaderEngine().createSession(readerFixtureBook()) + val preferences = ReaderToolbarPreferences( + hiddenToolIds = ReaderTool.entries + .filterNot { it == ReaderTool.VISUAL_OPTIONS } + .mapTo(mutableSetOf()) { it.id } + ) + + val model = epubReaderWorkspaceModel( + session = session, + toolbarPreferences = preferences, + extrasState = ReaderExtrasState(), + aiAvailable = true + ) + + assertFalse(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections) + assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions) + } + + @Test + fun `epub workspace ignores external lookup in inspector`() { + val session = ReaderEngine().createSession(readerFixtureBook()) + val preferences = ReaderToolbarPreferences( + hiddenToolIds = ReaderTool.entries + .filterNot { it == ReaderTool.DICTIONARY } + .mapTo(mutableSetOf()) { it.id } + ) + + val model = epubReaderWorkspaceModel( + session = session, + toolbarPreferences = preferences, + extrasState = ReaderExtrasState(), + aiAvailable = false, + cloudTtsAvailable = false, + externalLookupAvailable = true + ) + + assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections) + assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions) + } + @Test fun `toolbar quick actions preserve visibility order and bottom placement`() { val preferences = ReaderToolbarPreferences( @@ -104,6 +158,34 @@ class ReaderWorkspaceModelsTest { assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi) } + @Test + fun `toolbar quick actions hide online tools when unavailable`() { + val preferences = ReaderToolbarPreferences( + toolOrder = listOf( + ReaderTool.DICTIONARY, + ReaderTool.SEARCH, + ReaderTool.AI_FEATURES, + ReaderTool.TTS_CONTROLS + ) + ReaderTool.entries, + bottomToolIds = setOf( + ReaderTool.DICTIONARY.id, + ReaderTool.SEARCH.id, + ReaderTool.AI_FEATURES.id, + ReaderTool.TTS_CONTROLS.id + ) + ) + + val tools = readerWorkspaceQuickActionTools( + toolbarPreferences = preferences, + bottom = true, + aiAvailable = false, + cloudTtsAvailable = false, + externalLookupAvailable = false + ) + + assertEquals(listOf(ReaderTool.SEARCH), tools) + } + @Test fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() { val model = pdfReaderWorkspaceModel( @@ -124,14 +206,26 @@ class ReaderWorkspaceModelsTest { assertEquals(ReaderWorkspaceKind.PDF, model.kind) assertNull(model.defaultPdfInteractionMode) - assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections) - assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) - assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections) - assertTrue(ReaderWorkspaceLeftSection.NOTES in model.leftSections) + assertEquals( + listOf( + ReaderWorkspaceLeftSection.CONTENTS, + ReaderWorkspaceLeftSection.NOTES, + ReaderWorkspaceLeftSection.BOOKMARKS, + ReaderWorkspaceLeftSection.PAGES + ), + model.leftSections + ) + assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections) assertTrue(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections) assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections) + assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections) + assertTrue(ReaderWorkspaceTopAction.BOOKMARK in model.topActions) + assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions) assertTrue(ReaderWorkspaceTopAction.AI in model.topActions) + assertFalse(model.panelDefaults.leftOpen) + assertFalse(model.panelDefaults.inspectorOpen) + assertFalse(model.chrome.preferAutoHide) } @Test @@ -156,6 +250,7 @@ class ReaderWorkspaceModelsTest { ) assertTrue(model.chrome.forceVisible) + assertFalse(model.chrome.preferAutoHide) assertTrue("search" in model.chrome.forceVisibleReasons) assertTrue("annotation" in model.chrome.forceVisibleReasons) assertTrue("error" in model.chrome.forceVisibleReasons) @@ -163,4 +258,19 @@ class ReaderWorkspaceModelsTest { assertTrue("tts" in model.chrome.forceVisibleReasons) assertFalse(ReaderWorkspaceTopAction.AI in model.topActions) } + + private fun readerFixtureBook(): SharedEpubBook { + return SharedEpubBook( + id = "reader_fixture", + fileName = "Reader Fixture.epub", + title = "Reader Fixture", + chapters = listOf( + SharedEpubChapter( + id = "intro", + title = "Intro", + plainText = "A short reader fixture for workspace model tests." + ) + ) + ) + } } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt index bbb7629..7813c8c 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt @@ -50,6 +50,26 @@ class SharedAppThemeColorMathTest { assertTrue(abs(original.blue - roundTripped.blue) < 0.01f) } + @Test + fun `hsv wheel maps center to no saturation and right edge to red`() { + val center = sharedHsvWheelSelection( + offsetX = 50f, + offsetY = 50f, + width = 100f, + height = 100f + ) + val rightEdge = sharedHsvWheelSelection( + offsetX = 100f, + offsetY = 50f, + width = 100f, + height = 100f + ) + + assertClose(0f, center.saturation) + assertClose(0f, rightEdge.hue) + assertClose(1f, rightEdge.saturation) + } + private fun assertClose(expected: Float, actual: Float) { assertTrue(abs(expected - actual) < 0.01f, "Expected $expected but was $actual") } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt new file mode 100644 index 0000000..2a503a9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt @@ -0,0 +1,83 @@ +package com.aryan.reader.shared.ui + +import com.aryan.reader.shared.HighlightColor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SharedNativePaginatedReaderInteractionTest { + @Test + fun `word selection trims punctuation around long press range`() { + val range = sharedNativeReaderTrimmedWordRange( + text = "\"Reader,\" she said.", + start = 0, + end = 9 + ) + + assertNotNull(range) + assertEquals(1, range.start) + assertEquals(7, range.end) + } + + @Test + fun `word selection ignores punctuation only range`() { + val range = sharedNativeReaderTrimmedWordRange( + text = "...", + start = 0, + end = 3 + ) + + assertNull(range) + } + + @Test + fun `highlight for native selection keeps desktop locator offsets`() { + val selection = SharedNativeReaderTextSelection( + chapterIndex = 2, + pageIndex = 7, + startOffset = 120, + endOffset = 136, + text = "selected passage" + ) + + val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.YELLOW) + + assertEquals("desktop:2:120:136", highlight.cfi) + assertEquals(2, highlight.chapterIndex) + assertEquals(7, highlight.locator.pageIndex) + assertEquals(120, highlight.locator.startOffset) + assertEquals(136, highlight.locator.endOffset) + assertEquals("selected passage", highlight.locator.textQuote) + } + + @Test + fun `highlight for block selection keeps android style cfi and locator offsets`() { + val selection = SharedNativeReaderTextSelection( + chapterIndex = 1, + pageIndex = 4, + startOffset = 105, + endOffset = 220, + text = "selected across blocks", + startPageIndex = 4, + endPageIndex = 4, + startBlockIndex = 8, + endBlockIndex = 10, + startBlockCharOffset = 100, + endBlockCharOffset = 200, + startLocalOffset = 5, + endLocalOffset = 20, + startBaseCfi = "/4/2/8", + endBaseCfi = "/4/2/10" + ) + + val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.GREEN) + + assertEquals("/4/2/8:5|/4/2/10:20", highlight.cfi) + assertEquals(1, highlight.chapterIndex) + assertEquals(4, highlight.locator.pageIndex) + assertEquals(105, highlight.locator.startOffset) + assertEquals(220, highlight.locator.endOffset) + assertEquals("selected across blocks", highlight.locator.textQuote) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedReaderModalSizingTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedReaderModalSizingTest.kt new file mode 100644 index 0000000..75f3883 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedReaderModalSizingTest.kt @@ -0,0 +1,19 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedReaderModalSizingTest { + + @Test + fun `reader popup width is capped on wide surfaces`() { + assertEquals(SharedReaderPopupDefaultMaxWidth, sharedReaderPopupWidth(1200.dp)) + } + + @Test + fun `reader popup width stays usable on narrow surfaces`() { + assertEquals(320.dp, sharedReaderPopupWidth(500.dp)) + assertEquals(280.dp, sharedReaderPopupWidth(280.dp)) + } +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.desktop.kt new file mode 100644 index 0000000..6874961 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/reader/SharedReaderDiagnostics.desktop.kt @@ -0,0 +1,23 @@ +package com.aryan.reader.shared.reader + +private val SharedReaderDiagnosticTags: Set = + System.getProperty(SharedReaderDiagnosticsTagsProperty) + .orEmpty() + .split(',', ';', ' ', '\t', '\n') + .mapNotNull { rawTag -> + rawTag.trim() + .takeIf { it.isNotBlank() } + ?.lowercase() + } + .toSet() + +internal actual val SharedReaderDiagnosticsEnabled: Boolean = + System.getProperty(SharedReaderDiagnosticsProperty) + ?.trim() + ?.equals("true", ignoreCase = true) == true || + SharedReaderDiagnosticTags.isNotEmpty() + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (SharedReaderDiagnosticTags.isEmpty()) return true + return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCache.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCache.kt new file mode 100644 index 0000000..c5a3d3e --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCache.kt @@ -0,0 +1,105 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image as SkiaImage +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +internal object DesktopBookCoverImageCache { + private const val MaxEntries = 96 + private const val MaxCoverDimensionPx = 512 + + private data class Entry( + val length: Long, + val lastModified: Long, + val bitmap: ImageBitmap + ) + + private val entries = LinkedHashMap(MaxEntries, 0.75f, true) + + fun peek(path: String): ImageBitmap? { + val file = File(path) + if (!file.isFile) return null + val key = file.absolutePath + val length = file.length() + val lastModified = file.lastModified() + return synchronized(entries) { + val entry = entries[key] + if (entry != null && entry.length == length && entry.lastModified == lastModified) { + entry.bitmap + } else { + entries.remove(key) + null + } + } + } + + fun load(path: String): ImageBitmap? { + peek(path)?.let { return it } + val file = File(path) + if (!file.isFile) return null + val bitmap = decodeCover(file) ?: return null + val entry = Entry( + length = file.length(), + lastModified = file.lastModified(), + bitmap = bitmap + ) + synchronized(entries) { + entries[file.absolutePath] = entry + trimToMaxEntries() + } + return bitmap + } + + fun clearForTests() { + synchronized(entries) { + entries.clear() + } + } + + private fun trimToMaxEntries() { + while (entries.size > MaxEntries) { + val eldestKey = entries.keys.firstOrNull() ?: return + entries.remove(eldestKey) + } + } + + private fun decodeCover(file: File): ImageBitmap? { + runCatching { ImageIO.read(file) }.getOrNull() + ?.scaledToFit(MaxCoverDimensionPx) + ?.toComposeImageBitmap() + ?.let { return it } + + return runCatching { + SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap() + }.getOrNull() + } + + private fun BufferedImage.scaledToFit(maxDimension: Int): BufferedImage { + val largestDimension = maxOf(width, height) + if (largestDimension <= maxDimension) return this + val scale = maxDimension.toDouble() / largestDimension.toDouble() + val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) + val target = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = target.createGraphics() + try { + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + graphics.setRenderingHint( + RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY + ) + graphics.drawImage(this, 0, 0, targetWidth, targetHeight, null) + } finally { + graphics.dispose() + } + return target + } +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopEpubNativeImage.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopEpubNativeImage.desktop.kt new file mode 100644 index 0000000..64e4e2f --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/DesktopEpubNativeImage.desktop.kt @@ -0,0 +1,174 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.Image +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import com.aryan.reader.paginatedreader.SemanticImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage +import java.io.ByteArrayInputStream +import java.io.File +import java.util.Base64 +import javax.imageio.ImageIO + +@Composable +fun DesktopEpubNativeImage( + image: SemanticImage, + modifier: Modifier = Modifier +) { + var bitmap by remember(image.path) { + mutableStateOf(DesktopEpubNativeImageCache.peek(image.path)) + } + + LaunchedEffect(image.path) { + if (bitmap == null) { + bitmap = withContext(Dispatchers.IO) { + DesktopEpubNativeImageCache.load(image.path) + } + } + } + + val currentBitmap = bitmap + if (currentBitmap != null) { + Image( + bitmap = currentBitmap, + contentDescription = image.altText ?: "Image from EPUB", + modifier = modifier, + contentScale = ContentScale.Fit, + colorFilter = image.readerImageColorFilter() + ) + } else { + Text( + text = image.altText?.takeIf { it.isNotBlank() } ?: image.path.substringAfterLast('/').substringAfterLast('\\'), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + style = MaterialTheme.typography.bodySmall + ) + } +} + +private object DesktopEpubNativeImageCache { + private const val MaxEntries = 160 + + private data class Entry( + val length: Long?, + val lastModified: Long?, + val bitmap: ImageBitmap + ) + + private val entries = LinkedHashMap(MaxEntries, 0.75f, true) + + fun peek(path: String): ImageBitmap? { + val source = DesktopEpubImageSource.from(path) ?: return null + return synchronized(entries) { + val entry = entries[source.key] + if (entry != null && entry.length == source.length && entry.lastModified == source.lastModified) { + entry.bitmap + } else { + entries.remove(source.key) + null + } + } + } + + fun load(path: String): ImageBitmap? { + peek(path)?.let { return it } + val source = DesktopEpubImageSource.from(path) ?: return null + val bitmap = decode(source) ?: return null + synchronized(entries) { + entries[source.key] = Entry( + length = source.length, + lastModified = source.lastModified, + bitmap = bitmap + ) + trimToMaxEntries() + } + return bitmap + } + + private fun trimToMaxEntries() { + while (entries.size > MaxEntries) { + val eldestKey = entries.keys.firstOrNull() ?: return + entries.remove(eldestKey) + } + } + + private fun decode(source: DesktopEpubImageSource): ImageBitmap? { + val bytes = source.bytes() ?: return null + runCatching { + ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() + }.getOrNull()?.let { return it } + + return runCatching { + SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() + }.getOrNull() + } +} + +private sealed class DesktopEpubImageSource( + val key: String, + val length: Long?, + val lastModified: Long? +) { + abstract fun bytes(): ByteArray? + + data class FileSource(private val file: File) : DesktopEpubImageSource( + key = file.absolutePath, + length = file.length(), + lastModified = file.lastModified() + ) { + override fun bytes(): ByteArray? = runCatching { file.readBytes() }.getOrNull() + } + + data class DataUriSource(private val path: String) : DesktopEpubImageSource( + key = path, + length = path.length.toLong(), + lastModified = null + ) { + override fun bytes(): ByteArray? { + val marker = "base64," + val markerIndex = path.indexOf(marker, ignoreCase = true) + if (markerIndex < 0) return null + val base64 = path.substring(markerIndex + marker.length) + if (base64.isBlank()) return null + return runCatching { Base64.getDecoder().decode(base64) }.getOrNull() + } + } + + companion object { + fun from(path: String): DesktopEpubImageSource? { + if (path.startsWith("data:image/", ignoreCase = true)) { + return DataUriSource(path) + } + val file = File(path) + return if (file.isFile) FileSource(file) else null + } + } +} + +private fun SemanticImage.readerImageColorFilter(): ColorFilter? { + if (style.blockStyle.filter != "invert(100%)") return null + return ColorFilter.colorMatrix( + ColorMatrix( + floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ) + ) +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt index 383c1b7..ac7ae01 100644 --- a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt @@ -1,13 +1,16 @@ package com.aryan.reader.shared.ui import androidx.compose.foundation.Image +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale -import org.jetbrains.skia.Image as SkiaImage -import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable internal actual fun LocalBookCoverImage( @@ -15,19 +18,21 @@ internal actual fun LocalBookCoverImage( contentDescription: String?, modifier: Modifier ) { - val bitmap = remember(path) { - runCatching { - val file = File(path) - if (!file.isFile) { - null - } else { - SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap() - } - }.getOrNull() + var bitmap by remember(path) { + mutableStateOf(DesktopBookCoverImageCache.peek(path)) } + + LaunchedEffect(path) { + if (bitmap == null) { + bitmap = withContext(Dispatchers.IO) { + DesktopBookCoverImageCache.load(path) + } + } + } + if (bitmap != null) { Image( - bitmap = bitmap, + bitmap = bitmap!!, contentDescription = contentDescription, modifier = modifier, contentScale = ContentScale.Crop diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.desktop.kt new file mode 100644 index 0000000..7d5a03a --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayer.desktop.kt @@ -0,0 +1,133 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.Window as ComposeWindow +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.delay +import java.awt.EventQueue +import java.awt.KeyboardFocusManager +import java.awt.Window as AwtWindow + +@Composable +internal actual fun SharedReaderModalLayer( + onDismiss: () -> Unit, + level: SharedReaderModalLevel, + content: @Composable () -> Unit +) { + val anchor = LocalSharedReaderModalAnchorBounds.current + val density = LocalDensity.current + val ownerWindow = remember { currentNonModalOwnerWindow() } + val dialogSize = with(density) { + anchor?.let { + DpSize( + width = it.widthPx.toDp().coerceAtLeast(360.dp), + height = it.heightPx.toDp().coerceAtLeast(360.dp) + ) + } ?: DpSize(720.dp, 620.dp) + } + val dialogPosition = with(density) { + val ownerLocation = ownerWindow?.let { window -> + runCatching { window.locationOnScreen }.getOrNull() + } + if (anchor != null && ownerLocation != null) { + WindowPosition( + (ownerLocation.x + anchor.leftPx).toDp(), + (ownerLocation.y + anchor.topPx).toDp() + ) + } else { + WindowPosition(Alignment.Center) + } + } + val state = rememberWindowState(position = dialogPosition, size = dialogSize) + val windowTitle = when (level) { + SharedReaderModalLevel.Panel -> "Reader Panel" + SharedReaderModalLevel.Popup -> "Reader Popup" + } + + LaunchedEffect(dialogPosition, dialogSize) { + state.position = dialogPosition + state.size = dialogSize + } + DisposableEffect(ownerWindow) { + onDispose { + ownerWindow?.restoreFocusAfterSharedReaderModal() + } + } + + ComposeWindow( + onCloseRequest = onDismiss, + state = state, + title = windowTitle, + undecorated = true, + transparent = true, + resizable = false, + alwaysOnTop = true, + focusable = true + ) { + val modalWindow = window + LaunchedEffect(modalWindow, level) { + modalWindow.name = SharedReaderModalWindowNamePrefix + level.name + modalWindow.isAlwaysOnTop = true + val frontAttempts = if (level == SharedReaderModalLevel.Popup) 4 else 3 + repeat(frontAttempts) { attempt -> + delay(if (attempt == 0) 30L else 80L) + modalWindow.isAlwaysOnTop = true + modalWindow.toFront() + modalWindow.requestFocus() + modalWindow.requestFocusInWindow() + } + } + content() + } +} + +private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:" + +private fun AwtWindow.restoreFocusAfterSharedReaderModal() { + EventQueue.invokeLater { + if (!isDisplayable || !isShowing) return@invokeLater + if (this is java.awt.Frame && extendedState and java.awt.Frame.ICONIFIED != 0) { + extendedState = extendedState and java.awt.Frame.ICONIFIED.inv() + } + toFront() + requestFocus() + requestFocusInWindow() + focusOwner?.requestFocus() + } +} + +private fun currentNonModalOwnerWindow(): AwtWindow? { + val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + if (activeWindow != null && !activeWindow.isSharedReaderModalWindow()) { + return activeWindow + } + return AwtWindow.getWindows() + .filter { window -> window.isShowing && window.isDisplayable && !window.isSharedReaderModalWindow() } + .maxByOrNull { window -> + when { + window.isFocused -> 3 + window.isActive -> 2 + window.isVisible -> 1 + else -> 0 + } + } +} + +private fun AwtWindow.isSharedReaderModalWindow(): Boolean { + val windowTitle = when (this) { + is java.awt.Dialog -> title + is java.awt.Frame -> title + else -> "" + } + return name?.startsWith(SharedReaderModalWindowNamePrefix) == true || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Popup") +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt index 5abf8a9..9eee62d 100644 --- a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt @@ -1,7 +1,6 @@ package com.aryan.reader.shared import java.nio.file.Files -import kotlin.io.path.toFile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubMetadataEditorTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubMetadataEditorTest.kt new file mode 100644 index 0000000..60a8054 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubMetadataEditorTest.kt @@ -0,0 +1,166 @@ +package com.aryan.reader.shared.reader + +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SharedEpubMetadataEditorTest { + @Test + fun `rewrite updates existing OPF metadata`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source, metadata = """ + + Old + Old Author + Old summary + + + + """.trimIndent()) + + val result = SharedEpubMetadataEditor.rewrite( + source = source, + destination = output, + update = update() + ) + + assertEquals("New Title", result.title) + assertEquals("New Author", result.author) + assertEquals("New summary", result.description) + assertEquals("New Series", result.seriesName) + assertEquals(2.5, result.seriesIndex) + assertEquals(result, SharedEpubMetadataEditor.readMetadata(output)) + } + + @Test + fun `rewrite creates missing metadata elements`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source, metadata = "") + + val result = SharedEpubMetadataEditor.rewrite(source, output, update()) + + assertEquals("New Title", result.title) + assertEquals("New Author", result.author) + assertEquals("New summary", result.description) + assertEquals("New Series", result.seriesName) + assertEquals(2.5, result.seriesIndex) + } + + @Test + fun `rewrite preserves non OPF zip entries`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source) + + SharedEpubMetadataEditor.rewrite(source, output, update()) + + ZipFile(output).use { zip -> + assertEquals("chapter", zip.getInputStream(assertNotNull(zip.getEntry("OEBPS/chapter.xhtml"))).reader().readText()) + } + } + + @Test + fun `rewrite keeps mimetype first and stored`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source) + + SharedEpubMetadataEditor.rewrite(source, output, update()) + + ZipInputStream(output.inputStream()).use { zip -> + val first = assertNotNull(zip.nextEntry) + assertEquals("mimetype", first.name) + assertEquals(ZipEntry.STORED, first.method) + } + } + + @Test + fun `rewrite in place rejects invalid epub without replacing source`() = withTempDir { dir -> + val source = File(dir, "broken.epub").apply { writeText("not an epub") } + val backup = File(dir, "backup.epub") + + assertFailsWith { + SharedEpubMetadataEditor.rewriteInPlace(source, backup, update()) + } + + assertEquals("not an epub", source.readText()) + assertTrue(!backup.exists()) + } + + private fun update(): SharedEpubMetadataUpdate { + return SharedEpubMetadataUpdate( + title = "New Title", + author = "New Author", + description = "New summary", + seriesName = "New Series", + seriesIndex = 2.5 + ) + } + + private fun writeEpub( + target: File, + metadata: String = """ + + Old + + """.trimIndent() + ) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putStoredText("mimetype", "application/epub+zip") + zip.putText( + "META-INF/container.xml", + """""" + ) + zip.putText( + "OEBPS/content.opf", + """ + + $metadata + + + + """.trimIndent() + ) + zip.putText("OEBPS/chapter.xhtml", "chapter") + } + } + + private fun ZipOutputStream.putText(name: String, text: String) { + putNextEntry(ZipEntry(name)) + write(text.toByteArray()) + closeEntry() + } + + private fun ZipOutputStream.putStoredText(name: String, text: String) { + val bytes = text.toByteArray() + val crc = CRC32().apply { update(bytes) }.value + val entry = ZipEntry(name).apply { + method = ZipEntry.STORED + size = bytes.size.toLong() + compressedSize = bytes.size.toLong() + this.crc = crc + } + putNextEntry(entry) + write(bytes) + closeEntry() + } + + private inline fun withTempDir(block: (File) -> Unit) { + val dir = createTempDir(prefix = "epub-metadata-test") + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt new file mode 100644 index 0000000..50aca19 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedEpubPaginationCacheTest.kt @@ -0,0 +1,147 @@ +package com.aryan.reader.shared.reader + +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SharedEpubPaginationCacheTest { + + @Test + fun `page cache round trips measured pages`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings(fontSize = 19, lineSpacing = 1.5f) + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 12, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 4, + endOffset = 15 + ) + ) + + cache.save(book, settings, viewport, pages) + val loaded = cache.load(book, settings, viewport) + + assertNotNull(loaded) + assertEquals(1, loaded.size) + assertEquals(0, loaded.first().pageIndex) + assertEquals("Cached page", loaded.first().text) + assertEquals(4, loaded.first().startOffset) + assertEquals(15, loaded.first().endOffset) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `page cache misses when viewport or chapter content changes`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 900, heightPx = 700) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + + cache.save(book, settings, viewport, pages) + + assertNull(cache.load(book, settings, viewport.copy(widthPx = 901))) + assertNull( + cache.load( + book.copy( + chapters = book.chapters.map { chapter -> + chapter.copy(plainText = chapter.plainText + " Changed.") + } + ), + settings, + viewport + ) + ) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `pagination cache key changes for spread mode`() { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val single = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE), viewport) + val spread = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE), viewport) + + assertFalse(single.configHash == spread.configHash) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `clear all removes persisted and memory pagination pages`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + + cache.save(book, settings, viewport, pages) + assertNotNull(cache.load(book, settings, viewport)) + + cache.clearAll() + + assertNull(cache.load(book, settings, viewport)) + } finally { + root.deleteRecursively() + } + } + + private fun cacheBook(): SharedEpubBook { + return SharedEpubBook( + id = "book-id", + fileName = "book.epub", + title = "Book", + author = "Author", + chapters = listOf( + SharedEpubChapter( + id = "chapter-1", + title = "One", + plainText = "Cached page content.", + htmlContent = "

Cached page content.

", + baseHref = "one.xhtml" + ) + ) + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoadCacheTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoadCacheTest.kt new file mode 100644 index 0000000..b56536d --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoadCacheTest.kt @@ -0,0 +1,78 @@ +package com.aryan.reader.shared.reader + +import com.aryan.reader.shared.FileType +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SharedJvmBookLoadCacheTest { + + @Test + fun `book load cache round trips parsed shared book`() { + val root = Files.createTempDirectory("reader-book-load-cache").toFile() + try { + val cache = SharedJvmBookLoadCache(root) + val key = SharedJvmBookLoadCacheKey( + canonicalPath = "C:/Books/book.epub", + type = FileType.EPUB, + length = 1234L, + lastModified = 5678L + ) + val book = SharedEpubBook( + id = "C:/Books/book.epub", + fileName = "book.epub", + title = "Cached Book", + author = "Author", + css = mapOf("style.css" to "p { margin: 0; }"), + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Hello cache.", + htmlContent = "

Hello cache.

", + baseHref = "one.xhtml" + ) + ) + ) + + cache.save(key, book) + val loaded = cache.load(key) + + assertNotNull(loaded) + assertEquals(book.title, loaded.title) + assertEquals(book.css, loaded.css) + assertEquals("Hello cache.", loaded.chapters.single().plainText) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `book load cache misses when source fingerprint changes`() { + val root = Files.createTempDirectory("reader-book-load-cache").toFile() + try { + val cache = SharedJvmBookLoadCache(root) + val key = SharedJvmBookLoadCacheKey( + canonicalPath = "C:/Books/book.epub", + type = FileType.EPUB, + length = 1234L, + lastModified = 5678L + ) + val book = SharedEpubBook( + id = "C:/Books/book.epub", + fileName = "book.epub", + title = "Cached Book", + chapters = listOf(SharedEpubChapter("one", "One", "Hello cache.")) + ) + + cache.save(key, book) + + assertNull(cache.load(key.copy(lastModified = 5679L))) + assertNull(cache.load(key.copy(length = 1235L))) + } finally { + root.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt index d55695b..a2a2448 100644 --- a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt @@ -1,8 +1,10 @@ package com.aryan.reader.shared.reader +import com.aryan.reader.paginatedreader.SemanticImage import com.aryan.reader.shared.FileType import java.io.File import java.nio.file.Files +import java.util.Base64 import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.test.Test @@ -139,6 +141,60 @@ class SharedJvmBookLoaderTest { assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100) } + @Test + fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir -> + val file = File(dir, "image-book.epub") + writeZip(file) { + text( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + text( + "OPS/content.opf", + """ + + + Image Book + + + + + + + + + + """.trimIndent() + ) + text( + "OPS/chapter.xhtml", + """ + + +

One

+

Before

+ Pixel +

After

+ + + """.trimIndent() + ) + bytes("OPS/images/pixel.png", onePixelPng) + } + + val book = SharedJvmBookLoader.loadEpub(file) + val image = book.chapters.single().semanticBlocks.filterIsInstance().single() + + assertTrue(image.path.startsWith("data:image/png;base64,")) + assertEquals("Pixel", image.altText) + } + private fun withTempDir(block: (File) -> Unit) { val dir = Files.createTempDirectory("reader-shared-loader").toFile() try { @@ -193,9 +249,16 @@ class SharedJvmBookLoaderTest { private class ZipBuilder(private val zip: ZipOutputStream) { fun text(path: String, value: String) { + bytes(path, value.toByteArray(Charsets.UTF_8)) + } + + fun bytes(path: String, value: ByteArray) { zip.putNextEntry(ZipEntry(path)) - zip.write(value.toByteArray(Charsets.UTF_8)) + zip.write(value) zip.closeEntry() } } + + private val onePixelPng: ByteArray = + Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") } diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt new file mode 100644 index 0000000..1181b1b --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt @@ -0,0 +1,22 @@ +package com.aryan.reader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SharedJvmLruMemoryCacheTest { + @Test + fun `cache evicts least recently used entry`() { + val cache = SharedJvmLruMemoryCache(maxEntries = 2) + + cache["one"] = 1 + cache["two"] = 2 + assertEquals(1, cache["one"]) + + cache["three"] = 3 + + assertEquals(1, cache["one"]) + assertNull(cache["two"]) + assertEquals(3, cache["three"]) + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectoriesTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectoriesTest.kt new file mode 100644 index 0000000..c153ae9 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmUserDirectoriesTest.kt @@ -0,0 +1,32 @@ +package com.aryan.reader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedJvmUserDirectoriesTest { + @Test + fun `shared jvm cache root uses xdg cache on linux`() { + val root = sharedJvmEpistemeCacheRoot( + env = mapOf("XDG_CACHE_HOME" to "/tmp/reader-cache")::get, + userHome = "/home/reader", + osName = "Linux" + ) + + assertEquals("/tmp/reader-cache/episteme", root.portablePath()) + } + + @Test + fun `shared jvm cache root preserves windows appdata location`() { + val root = sharedJvmEpistemeCacheRoot( + env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")::get, + userHome = "C:/Users/reader", + osName = "Windows 11" + ) + + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", root.portablePath()) + } +} + +private fun java.io.File.portablePath(): String { + return path.replace('\\', '/') +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt new file mode 100644 index 0000000..c7e2538 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt @@ -0,0 +1,136 @@ +package com.aryan.reader.shared.reader + +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aryan.reader.paginatedreader.BlockStyle +import com.aryan.reader.paginatedreader.BoxBorders +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticParagraph +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SharedMeasuredEpubPaginatorTest { + + @Test + fun `two page geometry caps each page to rendered page width on wide viewports`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + margin = 48, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ), + viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200) + ) + + assertEquals(760, geometry.pageWidthPx) + assertEquals(1_104, geometry.pageHeightPx) + } + + @Test + fun `geometry does not invent minimum page space beyond the rendered viewport`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 80, + verticalMargin = 120 + ), + viewport = ReaderViewportSpec(widthPx = 300, heightPx = 220) + ) + + assertEquals(140, geometry.pageWidthPx) + assertEquals(1, geometry.pageHeightPx) + } + + @Test + fun `geometry scales css-sized page settings to measured desktop pixels`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 0, + verticalMargin = 0 + ), + viewport = ReaderViewportSpec(widthPx = 1_900, heightPx = 860), + densityScale = 1.25f + ) + + assertEquals(950, geometry.pageWidthPx) + assertEquals(860, geometry.pageHeightPx) + } + + @Test + fun `paragraph split trims whitespace and prepares continuation styling`() { + val paragraph = SemanticParagraph( + text = "Alpha beta gamma delta", + spans = emptyList(), + style = CssStyle( + paragraphStyle = ParagraphStyle( + textIndent = TextIndent(firstLine = 24.sp, restLine = 8.sp) + ), + blockStyle = BlockStyle( + margin = BoxBorders(top = 12.dp) + ) + ), + elementId = null, + cfi = null, + startCharOffsetInSource = 100, + blockIndex = 7 + ) + + val split = assertNotNull(splitSemanticTextBlockAtOffsetForPagination(paragraph, 11)) + + assertEquals("Alpha beta", split.first.text) + assertEquals(100, split.first.startCharOffsetInSource) + assertEquals("gamma delta", split.second.text) + assertEquals(112, split.second.startCharOffsetInSource) + assertEquals( + TextIndent(firstLine = 0.sp, restLine = 8.sp), + split.second.style.paragraphStyle.textIndent + ) + assertEquals(0.dp, split.second.style.blockStyle.margin.top) + } + + @Test + fun `pagination stack collapses adjacent margins and can ignore trailing bottom margin`() { + val items = listOf( + PaginationStackItem(contentHeightPx = 100, marginTopPx = 18, marginBottomPx = 18), + PaginationStackItem(contentHeightPx = 80, marginTopPx = 18, marginBottomPx = 18) + ) + + assertEquals( + 216, + collapsedPaginationStackHeight(items, includeTrailingBottomMargin = false) + ) + assertEquals( + 234, + collapsedPaginationStackHeight(items, includeTrailingBottomMargin = true) + ) + } + + @Test + fun `pagination stack prefix fitting includes trailing bottom margin`() { + val items = listOf( + PaginationStackItem(contentHeightPx = 100, marginTopPx = 10, marginBottomPx = 30), + PaginationStackItem(contentHeightPx = 80, marginTopPx = 10, marginBottomPx = 30) + ) + + assertEquals( + 1, + paginationStackPrefixCountThatFits( + items = items, + availableHeightPx = 220, + includeTrailingBottomMargin = true + ) + ) + assertEquals( + 2, + paginationStackPrefixCountThatFits( + items = items, + availableHeightPx = 220, + includeTrailingBottomMargin = false + ) + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCacheTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCacheTest.kt new file mode 100644 index 0000000..74284cb --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ui/DesktopBookCoverImageCacheTest.kt @@ -0,0 +1,69 @@ +package com.aryan.reader.shared.ui + +import java.awt.Color +import java.awt.image.BufferedImage +import java.nio.file.Files +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopBookCoverImageCacheTest { + + @Test + fun `cover cache reloads when source fingerprint changes`() { + val root = Files.createTempDirectory("reader-cover-cache").toFile() + try { + DesktopBookCoverImageCache.clearForTests() + val cover = root.resolve("cover.png") + writeImage(cover.absolutePath, width = 64, height = 64) + + val first = DesktopBookCoverImageCache.load(cover.absolutePath) + assertNotNull(first) + assertEquals(64, first.width) + + writeImage(cover.absolutePath, width = 96, height = 48) + cover.setLastModified(cover.lastModified() + 2_000L) + + val second = DesktopBookCoverImageCache.load(cover.absolutePath) + assertNotNull(second) + assertEquals(96, second.width) + assertEquals(48, second.height) + } finally { + DesktopBookCoverImageCache.clearForTests() + root.deleteRecursively() + } + } + + @Test + fun `large covers are cached at thumbnail size`() { + val root = Files.createTempDirectory("reader-cover-cache").toFile() + try { + DesktopBookCoverImageCache.clearForTests() + val cover = root.resolve("large-cover.png") + writeImage(cover.absolutePath, width = 1_200, height = 800) + + val bitmap = DesktopBookCoverImageCache.load(cover.absolutePath) + + assertNotNull(bitmap) + assertTrue(bitmap.width <= 512) + assertTrue(bitmap.height <= 512) + } finally { + DesktopBookCoverImageCache.clearForTests() + root.deleteRecursively() + } + } + + private fun writeImage(path: String, width: Int, height: Int) { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val graphics = image.createGraphics() + try { + graphics.color = Color(0x2A, 0x5C, 0x88) + graphics.fillRect(0, 0, width, height) + } finally { + graphics.dispose() + } + ImageIO.write(image, "png", java.io.File(path)) + } +} diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt index 294f045..7c99f82 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt @@ -36,8 +36,40 @@ import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode import org.jsoup.select.Selector +import java.util.ArrayDeque +import java.util.IdentityHashMap private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE) +private const val MAX_SEMANTIC_TEXT_BLOCK_CHARS = 32_000 +private const val TEXT_APPEND_SLICE_CHARS = 2_048 +private val semanticBlockDescendantTags = setOf( + "img", + "svg", + "math-placeholder", + "table", + "hr", + "div", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "blockquote", + "figure", + "article", + "aside", + "header", + "footer", + "nav", + "section", + "main" +) +private val forcedStandaloneSemanticTags = setOf("img", "svg", "math-placeholder", "hr", "table") interface HtmlResourceResolver { fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? @@ -91,6 +123,12 @@ private fun String.capitalizeWords(): String = if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else "" } +private data class SemanticTextChunk( + val text: String, + val spans: List, + val startCharOffsetInSource: Int +) + /** * The public entry point for converting HTML to a list of [SemanticBlock]s. * This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance. @@ -144,13 +182,14 @@ private class SemanticHtmlParser( private val adaptThemeColors: Boolean ) { private val styleCache = mutableMapOf() + private val semanticBlockDescendantCache = IdentityHashMap() private var combinedRules: OptimizedCssRules = cssRules private val currentFontFamilyMap: MutableMap = fontFamilyMap.toMutableMap() private var nextBlockIndex = 0 fun parse(html: String): List { val document = Jsoup.parse(html, chapterAbsPath) - val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() } + val inlineCssContent = document.head().getElementsByTag("style").joinToString(separator = "\n") { it.data() } if (inlineCssContent.isNotBlank()) { HtmlParserLog.d("Found inline