From 83dcafa4b6a3882e09d21474b99d56cf5cdfbedc Mon Sep 17 00:00:00 2001 From: Aryan Date: Tue, 2 Jun 2026 00:51:42 +0530 Subject: [PATCH] Windows ga (#358) * Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization --- .gitignore | 5 +- .idea/androidTestResultsUserPreferences.xml | 40 + app/build.gradle.kts | 4 +- .../assets/epub/reader_test_book.epub | Bin 0 -> 4715 bytes app/src/androidTest/fixtures/epub/README.md | 48 + .../fixtures/epub/build_reader_test_book.py | 39 + .../reader_test_book/META-INF/container.xml | 6 + .../OEBPS/chapters/chapter-01.xhtml | 26 + .../OEBPS/chapters/chapter-02.xhtml | 25 + .../OEBPS/chapters/chapter-03.xhtml | 24 + .../OEBPS/images/fixture-diagram.svg | 9 + .../epub/reader_test_book/OEBPS/nav.xhtml | 24 + .../epub/reader_test_book/OEBPS/package.opf | 23 + .../OEBPS/styles/reader-test.css | 21 + .../fixtures/epub/reader_test_book/mimetype | 1 + .../com/aryan/reader/AppNavigationTest.kt | 227 +- .../aryan/reader/HomeRecentFileCardTest.kt | 126 + .../aryan/reader/LibraryScreenContentTest.kt | 398 ++ .../epubreader/ChapterWebViewBridgeTest.kt | 75 +- .../reader/epubreader/EpubReaderLogicTest.kt | 154 +- .../reader/epubreader/EpubReaderScreenTest.kt | 818 ++++ .../reader/paginatedreader/CssParserTest.kt | 131 +- .../reader/paginatedreader/HtmlParserTest.kt | 77 +- .../PaginatedReaderDataTest.kt | 13 +- .../PaginatedReaderViewModelTest.kt | 186 +- .../reader/paginatedreader/PaginatorTest.kt | 116 +- .../paginatedreader/ReaderLinkHitTest.kt | 103 + .../com/aryan/reader/pdf/PdfAnnotationTest.kt | 77 +- .../aryan/reader/pdf/PdfViewerScreenTest.kt | 303 +- .../reader/epubreader/EpubTestActivity.kt | 4 +- app/src/main/AndroidManifest.xml | 13 + app/src/main/assets/epub_reader.js | 497 ++- .../aryan/reader/AndroidSettingsHubModels.kt | 2 + .../aryan/reader/AndroidSharedStateBridge.kt | 6 +- .../java/com/aryan/reader/AppNavigation.kt | 24 +- .../com/aryan/reader/BookReplacementStore.kt | 80 + .../aryan/reader/BookWordReplacementsSheet.kt | 400 ++ .../reader/CloudEpubAnnotationMetadata.kt | 51 + .../CloudPdfAnnotationSidecarDecisions.kt | 75 + .../java/com/aryan/reader/CloudSyncTrace.kt | 70 + app/src/main/java/com/aryan/reader/Common.kt | 9 +- .../main/java/com/aryan/reader/FileHasher.kt | 6 +- .../java/com/aryan/reader/FolderSyncWorker.kt | 82 +- .../main/java/com/aryan/reader/FontsScreen.kt | 179 +- .../main/java/com/aryan/reader/HomeScreen.kt | 9 +- .../java/com/aryan/reader/LibraryModels.kt | 1 + .../java/com/aryan/reader/LibraryScreen.kt | 177 +- .../java/com/aryan/reader/MainViewModel.kt | 1364 +++++- .../aryan/reader/MetadataExtractionWorker.kt | 21 +- .../java/com/aryan/reader/ReaderBrightness.kt | 105 +- .../aryan/reader/ReaderSliderChromeState.kt | 15 + .../com/aryan/reader/SharedComposables.kt | 115 +- .../com/aryan/reader/SharedModelMappers.kt | 202 +- .../com/aryan/reader/SyncedFolderPrefs.kt | 108 + .../java/com/aryan/reader/data/AppDatabase.kt | 23 +- .../com/aryan/reader/data/LocalSyncUtils.kt | 18 +- .../com/aryan/reader/data/RecentFileDao.kt | 8 +- .../com/aryan/reader/data/RecentFileEntity.kt | 6 +- .../com/aryan/reader/data/RecentFileItem.kt | 50 +- .../reader/data/RecentFilesRepository.kt | 93 +- .../reader/epub/CalibreBundleExtractor.kt | 27 +- .../java/com/aryan/reader/epub/EpubChapter.kt | 9 +- .../java/com/aryan/reader/epub/EpubUtils.kt | 35 +- .../java/com/aryan/reader/epub/Fb2Parser.kt | 51 +- .../java/com/aryan/reader/epub/OdtParser.kt | 26 +- .../aryan/reader/epub/SingleFileImporter.kt | 132 +- .../aryan/reader/epubreader/ChapterWebView.kt | 48 +- .../epubreader/EpubReaderAnnotations.kt | 13 +- .../reader/epubreader/EpubReaderContent.kt | 13 +- .../reader/epubreader/EpubReaderControls.kt | 578 ++- .../reader/epubreader/EpubReaderScreen.kt | 1272 +++++- .../reader/epubreader/EpubReaderSearch.kt | 281 +- .../reader/epubreader/EpubReaderSettings.kt | 26 +- .../EpubReaderVisualOptionsState.kt | 14 + .../com/aryan/reader/opds/OpdsRepository.kt | 9 +- .../AndroidHtmlParserPlatform.kt | 9 +- .../reader/paginatedreader/BookPaginator.kt | 289 +- .../reader/paginatedreader/ContentStyler.kt | 58 +- .../reader/paginatedreader/IPaginator.kt | 3 +- .../aryan/reader/paginatedreader/Locator.kt | 170 +- .../reader/paginatedreader/MathMLRenderer.kt | 22 +- .../reader/paginatedreader/PaginatedReader.kt | 3895 ++++++++++++++++- .../PaginatedReaderViewModel.kt | 6 + .../aryan/reader/paginatedreader/Paginator.kt | 593 ++- .../paginatedreader/ReaderLinkDiagnostics.kt | 247 ++ .../ReaderNavigationTargets.kt | 112 + .../paginatedreader/data/BookCacheDatabase.kt | 98 +- .../paginatedreader/data/BookCacheEntities.kt | 25 +- .../data/BookProcessingWorker.kt | 59 +- .../java/com/aryan/reader/pdf/PdfHelper.kt | 45 +- .../com/aryan/reader/pdf/PdfNavigationUI.kt | 43 - .../com/aryan/reader/pdf/PdfPageComposable.kt | 10 +- .../com/aryan/reader/pdf/PdfPreferences.kt | 66 +- .../com/aryan/reader/pdf/PdfSettingsSheets.kt | 14 +- .../java/com/aryan/reader/pdf/PdfToolbars.kt | 242 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 427 +- .../com/aryan/reader/pdf/RichTextSystem.kt | 11 + .../com/aryan/reader/pdf/UniversalDocument.kt | 5 +- .../reader/pdf/data/PdfAnnotationData.kt | 7 +- .../pdf/data/PdfAnnotationRepository.kt | 80 + .../reader/pdf/data/PdfHighlightRepository.kt | 17 +- .../reader/pdf/data/PdfTextBoxRepository.kt | 18 +- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 33 +- .../com/aryan/reader/tts/ReaderTtsMiniBar.kt | 10 +- .../aryan/reader/tts/ReaderTtsOverlaySize.kt | 42 + .../aryan/reader/tts/TtsPlaybackManager.kt | 27 +- .../java/com/aryan/reader/tts/TtsUtils.kt | 66 +- .../res/drawable-nodpi/account_circle.xml | 10 + app/src/main/res/drawable-nodpi/add.xml | 10 + .../main/res/drawable-nodpi/arrow_back.xml | 11 + .../res/drawable-nodpi/arrow_downward.xml | 10 + .../res/drawable-nodpi/arrow_drop_down.xml | 10 + .../main/res/drawable-nodpi/arrow_drop_up.xml | 10 + .../main/res/drawable-nodpi/arrow_forward.xml | 11 + .../main/res/drawable-nodpi/arrow_upward.xml | 10 + app/src/main/res/drawable-nodpi/book.xml | 10 + .../res/drawable-nodpi/bookmark_border.xml | 10 + app/src/main/res/drawable-nodpi/brush.xml | 10 + .../main/res/drawable-nodpi/bug_report.xml | 10 + app/src/main/res/drawable-nodpi/check.xml | 10 + .../main/res/drawable-nodpi/chevron_left.xml | 11 + .../main/res/drawable-nodpi/chevron_right.xml | 11 + app/src/main/res/drawable-nodpi/cloud.xml | 10 + .../res/drawable-nodpi/cloud_download.xml | 10 + app/src/main/res/drawable-nodpi/code.xml | 10 + .../main/res/drawable-nodpi/collapse_all.xml | 10 + .../main/res/drawable-nodpi/content_copy.xml | 10 + app/src/main/res/drawable-nodpi/copy_all.xml | 10 + .../res/drawable-nodpi/create_new_folder.xml | 10 + app/src/main/res/drawable-nodpi/delete.xml | 10 + .../main/res/drawable-nodpi/description.xml | 10 + app/src/main/res/drawable-nodpi/devices.xml | 10 + .../main/res/drawable-nodpi/do_not_touch.xml | 10 + app/src/main/res/drawable-nodpi/download.xml | 10 + app/src/main/res/drawable-nodpi/edit.xml | 10 + .../main/res/drawable-nodpi/expand_all.xml | 10 + .../main/res/drawable-nodpi/expand_less.xml | 10 + .../main/res/drawable-nodpi/expand_more.xml | 10 + app/src/main/res/drawable-nodpi/favorite.xml | 10 + .../res/drawable-nodpi/favorite_border.xml | 10 + app/src/main/res/drawable-nodpi/file_open.xml | 10 + .../main/res/drawable-nodpi/filter_list.xml | 10 + app/src/main/res/drawable-nodpi/folder.xml | 10 + .../res/drawable-nodpi/folder_special.xml | 10 + .../drawable-nodpi/format_list_numbered.xml | 10 + .../main/res/drawable-nodpi/fullscreen.xml | 10 + .../res/drawable-nodpi/fullscreen_exit.xml | 10 + app/src/main/res/drawable-nodpi/gavel.xml | 10 + .../main/res/drawable-nodpi/graphic_eq.xml | 10 + .../main/res/drawable-nodpi/import_export.xml | 10 + app/src/main/res/drawable-nodpi/info.xml | 10 + app/src/main/res/drawable-nodpi/keep.xml | 10 + .../drawable-nodpi/keyboard_arrow_down.xml | 10 + .../drawable-nodpi/keyboard_arrow_left.xml | 11 + .../drawable-nodpi/keyboard_arrow_right.xml | 11 + .../res/drawable-nodpi/keyboard_arrow_up.xml | 10 + app/src/main/res/drawable-nodpi/list.xml | 11 + app/src/main/res/drawable-nodpi/lock.xml | 10 + app/src/main/res/drawable-nodpi/lock_open.xml | 10 + app/src/main/res/drawable-nodpi/mail.xml | 10 + app/src/main/res/drawable-nodpi/menu.xml | 10 + app/src/main/res/drawable-nodpi/menu_book.xml | 11 + app/src/main/res/drawable-nodpi/more_vert.xml | 10 + .../main/res/drawable-nodpi/my_location.xml | 10 + .../res/drawable-nodpi/navigate_before.xml | 10 + .../main/res/drawable-nodpi/navigate_next.xml | 10 + .../main/res/drawable-nodpi/open_in_new.xml | 11 + .../main/res/drawable-nodpi/phone_android.xml | 10 + .../main/res/drawable-nodpi/play_arrow.xml | 10 + app/src/main/res/drawable-nodpi/policy.xml | 10 + .../main/res/drawable-nodpi/psychology.xml | 10 + app/src/main/res/drawable-nodpi/push_pin.xml | 10 + app/src/main/res/drawable-nodpi/redo.xml | 11 + app/src/main/res/drawable-nodpi/refresh.xml | 10 + app/src/main/res/drawable-nodpi/remove.xml | 10 + app/src/main/res/drawable-nodpi/restore.xml | 10 + app/src/main/res/drawable-nodpi/save.xml | 10 + .../res/drawable-nodpi/screen_rotation.xml | 10 + app/src/main/res/drawable-nodpi/settings.xml | 10 + .../settings_backup_restore.xml | 11 + app/src/main/res/drawable-nodpi/share.xml | 10 + .../main/res/drawable-nodpi/smartphone.xml | 10 + app/src/main/res/drawable-nodpi/star.xml | 10 + app/src/main/res/drawable-nodpi/stop.xml | 10 + .../main/res/drawable-nodpi/swap_horiz.xml | 10 + .../main/res/drawable-nodpi/text_fields.xml | 10 + .../res/drawable-nodpi/text_select_start.xml | 10 + app/src/main/res/drawable-nodpi/touch_app.xml | 10 + app/src/main/res/drawable-nodpi/tune.xml | 10 + .../main/res/drawable-nodpi/upload_file.xml | 10 + app/src/main/res/drawable-nodpi/verified.xml | 10 + .../main/res/drawable-nodpi/verified_user.xml | 10 + .../main/res/drawable-nodpi/visibility.xml | 10 + .../res/drawable-nodpi/visibility_off.xml | 10 + app/src/main/res/drawable-nodpi/volume_up.xml | 11 + app/src/main/res/values-vi/strings.xml | 426 ++ app/src/main/res/values/plurals.xml | 67 +- app/src/main/res/values/strings.xml | 368 +- .../aryan/reader/data/FirestoreRepository.kt | 2 + .../reader/data/GoogleDriveRepository.kt | 5 +- .../data/FirestoreRepositoryMappingTest.kt | 30 + ...GoogleDriveRepositoryUploadMetadataTest.kt | 18 + .../com/aryan/reader/AndroidLegalLinksTest.kt | 22 + .../reader/AndroidSettingsHubModelsTest.kt | 33 +- .../AndroidStringFormatResourcesTest.kt | 38 + .../aryan/reader/AppLanguageOptionsTest.kt | 13 + .../aryan/reader/BookReplacementHtmlTest.kt | 73 + .../reader/CloudEpubAnnotationMetadataTest.kt | 97 + .../CloudPdfAnnotationSidecarDecisionsTest.kt | 167 + .../com/aryan/reader/FileTypeResolverTest.kt | 3 + .../com/aryan/reader/MainViewModelTest.kt | 227 +- .../reader/ReaderBrightnessSettingsTest.kt | 12 +- .../reader/ReaderSliderChromeStateTest.kt | 62 + .../aryan/reader/SharedModelMappersTest.kt | 104 + .../com/aryan/reader/SyncedFolderPrefsTest.kt | 69 + .../data/RecentFileDaoReadingPositionTest.kt | 2 + ...ecentFileItemReadingPositionMappingTest.kt | 3 + ...FilesRepositoryReadingPositionMergeTest.kt | 37 + .../reader/epub/EpubImportSecurityTest.kt | 132 + .../reader/epub/SingleFileImporterTest.kt | 31 +- .../ChapterWebViewHighlightJsonTest.kt | 38 + .../EpubReaderBridgeAndControlsTest.kt | 18 +- ...EpubReaderPreferencesAndAnnotationsTest.kt | 3 + .../reader/epubreader/EpubReaderSearchTest.kt | 18 + .../EpubReaderVisualOptionsStateTest.kt | 47 + .../epubreader/EpubTtsChunkMatchingTest.kt | 15 + .../com/aryan/reader/opds/OpdsParserTest.kt | 4 +- .../aryan/reader/opds/OpdsRepositoryTest.kt | 18 + .../AndroidHtmlResourceResolverTest.kt | 45 + .../paginatedreader/ContentStylerTest.kt | 87 + .../paginatedreader/LocatorConverterTest.kt | 65 +- .../NativeVerticalLocationTest.kt | 30 + .../PaginatedHighlightMappingTest.kt | 157 +- .../PaginatorMeasurementContractTest.kt | 26 + .../ReaderLinkAnnotationTest.kt | 51 + .../ReaderNavigationTargetsTest.kt | 83 + .../paginatedreader/data/BookCacheDaoTest.kt | 36 + .../reader/pdf/PdfReaderPreferencesTest.kt | 41 +- .../reader/pdf/PdfReaderRepositoryTest.kt | 86 + .../aryan/reader/pdf/PdfReaderRichTextTest.kt | 7 + .../PdfReaderSettingsAndSharedModelsTest.kt | 13 +- .../reader/tts/TtsCacheManagerSecurityTest.kt | 40 + .../reader/tts/TtsChunkNavigationTest.kt | 46 + .../com/aryan/reader/tts/TtsModePolicyTest.kt | 8 + desktopApp/build.gradle.kts | 510 ++- desktopApp/compose-desktop.pro | 10 +- .../DesktopAccountProfileRepository.kt | 78 +- .../reader/desktop/DesktopAiByokStore.kt | 171 +- .../com/aryan/reader/desktop/DesktopAiHub.kt | 175 - .../aryan/reader/desktop/DesktopAppHost.kt | 197 +- .../aryan/reader/desktop/DesktopAppState.kt | 39 +- .../aryan/reader/desktop/DesktopAtomicFile.kt | 56 + .../reader/desktop/DesktopBuildProfile.kt | 67 +- .../reader/desktop/DesktopCloudConfig.kt | 32 +- .../desktop/DesktopCloudRepositories.kt | 46 +- .../reader/desktop/DesktopCloudSidecarSync.kt | 241 +- .../aryan/reader/desktop/DesktopCloudSync.kt | 706 ++- .../desktop/DesktopCloudSyncDiagnostics.kt | 82 + .../reader/desktop/DesktopComicArchive.kt | 32 +- .../reader/desktop/DesktopDiagnostics.kt | 30 +- .../desktop/DesktopEpubBridgeParsing.kt | 58 +- .../reader/desktop/DesktopEpubPagination.kt | 40 + .../reader/desktop/DesktopEpubWebView.kt | 453 +- .../desktop/DesktopFeatureNoticePlacement.kt | 13 + .../desktop/DesktopFirebaseAuthRepository.kt | 58 +- .../desktop/DesktopFolderMetadataExtractor.kt | 12 +- .../desktop/DesktopFolderSyncFeedback.kt | 16 + .../desktop/DesktopGeminiCloudTtsAdapter.kt | 97 +- .../reader/desktop/DesktopLibraryDatabase.kt | 30 +- .../aryan/reader/desktop/DesktopLibraryUi.kt | 129 +- .../reader/desktop/DesktopLocalFolderSync.kt | 173 +- .../reader/desktop/DesktopPaidAiAdapter.kt | 54 +- .../reader/desktop/DesktopPdfAnnotationUi.kt | 464 +- .../reader/desktop/DesktopPdfAppearance.kt | 40 +- .../reader/desktop/DesktopPdfChromeUi.kt | 86 +- .../reader/desktop/DesktopPdfInspectorUi.kt | 386 +- .../reader/desktop/DesktopPdfKeyCommands.kt | 30 +- .../reader/desktop/DesktopPdfNavigationUi.kt | 324 +- .../aryan/reader/desktop/DesktopPdfPage.kt | 212 +- .../desktop/DesktopPdfPageInteractions.kt | 120 +- .../reader/desktop/DesktopPdfReaderScreen.kt | 1543 +++++-- .../reader/desktop/DesktopPdfScrubbing.kt | 28 + .../reader/desktop/DesktopPdfSelectionUi.kt | 85 +- .../desktop/DesktopPdfSidecarEffects.kt | 56 +- .../reader/desktop/DesktopPdfSidecars.kt | 69 +- .../reader/desktop/DesktopPdfSyncSidecars.kt | 156 + .../aryan/reader/desktop/DesktopPdfTheme.kt | 28 +- .../aryan/reader/desktop/DesktopPdfZoom.kt | 368 +- .../reader/desktop/DesktopPlatformPaths.kt | 10 +- .../aryan/reader/desktop/DesktopProScreen.kt | 76 +- .../reader/desktop/DesktopProfileAvatar.kt | 114 + .../reader/desktop/DesktopReaderDefaults.kt | 91 + .../desktop/DesktopReaderDiagnostics.kt | 78 + .../reader/desktop/DesktopReaderOpenTrace.kt | 29 + .../reader/desktop/DesktopReaderOpening.kt | 3 +- .../reader/desktop/DesktopReaderPanels.kt | 152 +- .../reader/desktop/DesktopReaderScreen.kt | 656 ++- .../desktop/DesktopReaderTexturePreview.kt | 43 + .../reader/desktop/DesktopReaderTypography.kt | 2 +- .../desktop/DesktopReaderWindowState.kt | 37 +- .../com/aryan/reader/desktop/DesktopTtsLog.kt | 23 +- .../reader/desktop/DesktopWindowStateStore.kt | 4 + .../DesktopWindowsWebView2EpubWebView.kt | 1910 ++++++++ .../kotlin/com/aryan/reader/desktop/Main.kt | 2127 +++++++-- .../reader/desktop/DesktopAiByokStoreTest.kt | 48 + .../reader/desktop/DesktopAuthStoreTest.kt | 85 + .../reader/desktop/DesktopBuildProfileTest.kt | 142 +- .../reader/desktop/DesktopCloudConfigTest.kt | 60 + .../desktop/DesktopCloudSyncMappingTest.kt | 261 ++ .../reader/desktop/DesktopComicArchiveTest.kt | 56 + .../desktop/DesktopComposeInteropTest.kt | 13 +- .../desktop/DesktopEpubBridgeParsingTest.kt | 112 + .../desktop/DesktopEpubPaginationTest.kt | 119 + .../DesktopFeatureNoticePlacementTest.kt | 24 + .../desktop/DesktopLibraryDatabaseTest.kt | 55 + .../desktop/DesktopLocalFolderSyncTest.kt | 63 + .../reader/desktop/DesktopPaidAiUsageTest.kt | 15 + .../DesktopPdfNavigationSidebarTest.kt | 56 + .../reader/desktop/DesktopPdfReflowTest.kt | 2 + .../reader/desktop/DesktopPdfScrubbingTest.kt | 60 + .../reader/desktop/DesktopPdfSidecarsTest.kt | 16 + .../DesktopPdfTextHighlightStateTest.kt | 69 + .../reader/desktop/DesktopPdfThemeTest.kt | 36 +- .../desktop/DesktopPlatformPathsTest.kt | 2 - .../desktop/DesktopReaderDefaultsTest.kt | 355 ++ .../desktop/DesktopReaderKeyCommandsTest.kt | 174 + .../desktop/DesktopReaderTypographyTest.kt | 69 + .../desktop/DesktopReaderWindowStateTest.kt | 70 + .../reader/desktop/DesktopStartupTest.kt | 189 +- .../desktop/DesktopStringResourcesTest.kt | 18 + .../aryan/reader/desktop/DesktopTtsLogTest.kt | 18 + .../desktop/DesktopWebView2LayoutTest.kt | 44 + .../desktop/DesktopWindowStateStoreTest.kt | 6 + .../desktop/LinuxSecretToolCodecTest.kt | 55 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 2 +- shared/build.gradle.kts | 7 +- .../reader/SharedReaderDiagnostics.android.kt | 16 +- .../androidx/compose/material/icons/Icons.kt | 12 + .../filled/AutoMirroredFilledIcons.kt | 261 ++ .../material/icons/filled/FilledIcons.kt | 1519 +++++++ .../material/icons/outlined/OutlinedIcons.kt | 159 + .../aryan/reader/paginatedreader/CssParser.kt | 860 +++- .../paginatedreader/PaginatedReaderData.kt | 54 +- .../com/aryan/reader/shared/AppActions.kt | 1 + .../com/aryan/reader/shared/AppModels.kt | 1 + .../aryan/reader/shared/CloudSyncDecisions.kt | 102 + .../aryan/reader/shared/FileCapabilities.kt | 20 + .../com/aryan/reader/shared/LibraryModels.kt | 8 +- .../aryan/reader/shared/LibraryMutations.kt | 159 +- .../aryan/reader/shared/LocalFolderSync.kt | 34 +- .../reader/shared/ReaderAnnotationModels.kt | 83 +- .../shared/ReaderAnnotationSerializer.kt | 4 + .../reader/shared/ReaderAppearanceModels.kt | 96 +- .../aryan/reader/shared/ReaderExtrasModels.kt | 287 +- .../reader/shared/ReaderToolbarModels.kt | 9 +- .../reader/shared/ReaderTtsReplacements.kt | 82 +- .../reader/shared/ReaderWordReplacements.kt | 195 + .../aryan/reader/shared/SettingsHubModels.kt | 3 +- .../reader/shared/SharedFeaturePolicy.kt | 5 + .../aryan/reader/shared/SharedLegalLinks.kt | 34 + .../reader/shared/SharedLibrarySnapshot.kt | 55 +- .../com/aryan/reader/shared/SharedReducers.kt | 7 +- .../reader/shared/opds/SharedOpdsModels.kt | 14 +- .../reader/shared/opds/SharedOpdsUtilities.kt | 112 +- .../reader/shared/pdf/PdfInteractionModels.kt | 67 +- .../reader/shared/pdf/PdfReaderSession.kt | 194 +- .../reader/shared/pdf/PdfSelectionGeometry.kt | 4 +- .../reader/shared/pdf/PdfSpreadLayout.kt | 9 + .../pdf/SharedPdfAnnotationExportMapper.kt | 6 +- .../pdf/SharedPdfAnnotationSidecarCodec.kt | 132 +- .../reader/shared/pdf/SharedPdfRichText.kt | 11 + .../reader/shared/reader/ReaderEngine.kt | 266 +- .../reader/ReaderHtmlDocumentBuilder.kt | 1911 +++++++- .../reader/shared/reader/ReaderJumpHistory.kt | 4 + .../reader/shared/reader/ReaderModels.kt | 12 +- .../shared/reader/SharedReaderDiagnostics.kt | 4 +- .../reader/shared/ui/NonReaderLayoutModels.kt | 136 +- .../reader/shared/ui/NonReaderScreens.kt | 518 ++- .../shared/ui/ReaderContentRenderPlan.kt | 3 +- .../reader/shared/ui/ReaderMinimalSlider.kt | 77 +- .../aryan/reader/shared/ui/ReaderTooltips.kt | 130 + .../reader/shared/ui/ReaderWorkspaceModels.kt | 78 +- .../reader/shared/ui/ReaderWorkspaceShell.kt | 823 +++- .../aryan/reader/shared/ui/SharedAppShell.kt | 741 +++- .../shared/ui/SharedAppThemeSettings.kt | 367 +- .../reader/shared/ui/SharedLibraryDialogs.kt | 184 +- .../shared/ui/SharedNativePaginatedReader.kt | 1081 ++++- .../reader/shared/ui/SharedOpdsScreen.kt | 5 +- .../reader/shared/ui/SharedPdfAnnotationUi.kt | 1194 ++++- .../reader/shared/ui/SharedPdfRichTextUi.kt | 10 +- .../reader/shared/ui/SharedReaderChrome.kt | 1968 ++++++--- .../shared/ui/SharedReaderModalLayer.kt | 1 + .../shared/ui/SharedReaderScrollbars.kt | 201 +- .../ui/SharedReaderTtsOverlayControls.kt | 379 ++ .../reader/shared/ui/SharedSettingsHub.kt | 22 +- .../reader/shared/ui/SharedUtilityScreens.kt | 30 + .../reader/shared/CloudSyncDecisionsTest.kt | 122 + .../shared/EpubAnnotationSerializerTest.kt | 23 + .../reader/shared/FileCapabilitiesTest.kt | 11 +- .../shared/LocalFolderSyncEngineTest.kt | 63 +- .../shared/ReaderAppearanceModelsTest.kt | 155 + .../shared/ReaderBookReplacementEngineTest.kt | 103 + .../shared/ReaderDefaultSettingsStateTest.kt | 6 +- .../reader/shared/ReaderExtrasModelsTest.kt | 215 + .../shared/ReaderToolbarPreferencesTest.kt | 17 +- .../reader/shared/SettingsHubModelsTest.kt | 21 + .../reader/shared/SharedLegalLinksTest.kt | 18 + .../reader/shared/SharedLibraryEditorTest.kt | 107 + .../shared/SharedLibraryProjectorTest.kt | 6 + .../shared/SharedLibrarySnapshotJsonTest.kt | 30 +- .../shared/opds/SharedOpdsCatalogsTest.kt | 54 + .../reader/shared/pdf/PdfReaderSessionTest.kt | 54 + .../shared/pdf/PdfSelectionGeometryTest.kt | 16 + .../reader/shared/pdf/PdfSpreadLayoutTest.kt | 13 + .../pdf/SharedPdfAnnotationCommentsTest.kt | 43 + .../pdf/SharedPdfAnnotationSerializerTest.kt | 100 + .../shared/pdf/SharedPdfRichTextTest.kt | 7 + .../reader/shared/reader/ReaderEngineTest.kt | 146 + .../reader/ReaderHtmlDocumentBuilderTest.kt | 529 ++- .../shared/reader/ReaderSpreadLayoutTest.kt | 14 + .../shared/ui/NonReaderLayoutModelsTest.kt | 198 +- .../shared/ui/ReaderMinimalSliderTest.kt | 42 + .../shared/ui/ReaderWorkspaceModelsTest.kt | 336 +- ...redNativePaginatedReaderInteractionTest.kt | 294 ++ .../shared/ui/SharedPdfAnnotationUiTest.kt | 77 + .../reader/SharedReaderDiagnostics.desktop.kt | 17 +- .../shared/ui/DesktopBookCoverImageCache.kt | 37 +- .../shared/ui/LocalBookCoverImage.desktop.kt | 9 +- .../ui/SharedReaderModalLayer.desktop.kt | 255 +- .../paginatedreader/HtmlParserLinkTest.kt | 111 + .../shared/opds/SharedOpdsParserTest.kt | 83 + .../reader/SharedEpubPaginationCacheTest.kt | 91 + .../reader/SharedJvmBookLoadCacheTest.kt | 48 + .../shared/reader/SharedJvmBookLoaderTest.kt | 191 +- .../reader/SharedMeasuredEpubPaginatorTest.kt | 46 + .../ui/SharedReaderModalLayerDesktopTest.kt | 179 + .../reader/paginatedreader/HtmlParser.kt | 316 +- .../shared/ReaderTtsFileCacheManager.kt | 42 +- .../reader/shared/opds/SharedOpdsParser.kt | 134 +- .../reader/SharedEpubPaginationCache.kt | 312 +- .../shared/reader/SharedJvmBookLoadCache.kt | 53 +- .../shared/reader/SharedJvmBookLoader.kt | 333 +- .../reader/SharedMeasuredEpubPaginator.kt | 168 +- 444 files changed, 47279 insertions(+), 8096 deletions(-) create mode 100644 app/src/androidTest/assets/epub/reader_test_book.epub create mode 100644 app/src/androidTest/fixtures/epub/README.md create mode 100644 app/src/androidTest/fixtures/epub/build_reader_test_book.py create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css create mode 100644 app/src/androidTest/fixtures/epub/reader_test_book/mimetype create mode 100644 app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt create mode 100644 app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt create mode 100644 app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt create mode 100644 app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt create mode 100644 app/src/main/java/com/aryan/reader/BookReplacementStore.kt create mode 100644 app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt create mode 100644 app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt create mode 100644 app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt create mode 100644 app/src/main/java/com/aryan/reader/CloudSyncTrace.kt create mode 100644 app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt create mode 100644 app/src/main/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsState.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkDiagnostics.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/ReaderNavigationTargets.kt create mode 100644 app/src/main/java/com/aryan/reader/tts/ReaderTtsOverlaySize.kt create mode 100644 app/src/main/res/drawable-nodpi/account_circle.xml create mode 100644 app/src/main/res/drawable-nodpi/add.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_back.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_downward.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_drop_down.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_drop_up.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_forward.xml create mode 100644 app/src/main/res/drawable-nodpi/arrow_upward.xml create mode 100644 app/src/main/res/drawable-nodpi/book.xml create mode 100644 app/src/main/res/drawable-nodpi/bookmark_border.xml create mode 100644 app/src/main/res/drawable-nodpi/brush.xml create mode 100644 app/src/main/res/drawable-nodpi/bug_report.xml create mode 100644 app/src/main/res/drawable-nodpi/check.xml create mode 100644 app/src/main/res/drawable-nodpi/chevron_left.xml create mode 100644 app/src/main/res/drawable-nodpi/chevron_right.xml create mode 100644 app/src/main/res/drawable-nodpi/cloud.xml create mode 100644 app/src/main/res/drawable-nodpi/cloud_download.xml create mode 100644 app/src/main/res/drawable-nodpi/code.xml create mode 100644 app/src/main/res/drawable-nodpi/collapse_all.xml create mode 100644 app/src/main/res/drawable-nodpi/content_copy.xml create mode 100644 app/src/main/res/drawable-nodpi/copy_all.xml create mode 100644 app/src/main/res/drawable-nodpi/create_new_folder.xml create mode 100644 app/src/main/res/drawable-nodpi/delete.xml create mode 100644 app/src/main/res/drawable-nodpi/description.xml create mode 100644 app/src/main/res/drawable-nodpi/devices.xml create mode 100644 app/src/main/res/drawable-nodpi/do_not_touch.xml create mode 100644 app/src/main/res/drawable-nodpi/download.xml create mode 100644 app/src/main/res/drawable-nodpi/edit.xml create mode 100644 app/src/main/res/drawable-nodpi/expand_all.xml create mode 100644 app/src/main/res/drawable-nodpi/expand_less.xml create mode 100644 app/src/main/res/drawable-nodpi/expand_more.xml create mode 100644 app/src/main/res/drawable-nodpi/favorite.xml create mode 100644 app/src/main/res/drawable-nodpi/favorite_border.xml create mode 100644 app/src/main/res/drawable-nodpi/file_open.xml create mode 100644 app/src/main/res/drawable-nodpi/filter_list.xml create mode 100644 app/src/main/res/drawable-nodpi/folder.xml create mode 100644 app/src/main/res/drawable-nodpi/folder_special.xml create mode 100644 app/src/main/res/drawable-nodpi/format_list_numbered.xml create mode 100644 app/src/main/res/drawable-nodpi/fullscreen.xml create mode 100644 app/src/main/res/drawable-nodpi/fullscreen_exit.xml create mode 100644 app/src/main/res/drawable-nodpi/gavel.xml create mode 100644 app/src/main/res/drawable-nodpi/graphic_eq.xml create mode 100644 app/src/main/res/drawable-nodpi/import_export.xml create mode 100644 app/src/main/res/drawable-nodpi/info.xml create mode 100644 app/src/main/res/drawable-nodpi/keep.xml create mode 100644 app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml create mode 100644 app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml create mode 100644 app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml create mode 100644 app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml create mode 100644 app/src/main/res/drawable-nodpi/list.xml create mode 100644 app/src/main/res/drawable-nodpi/lock.xml create mode 100644 app/src/main/res/drawable-nodpi/lock_open.xml create mode 100644 app/src/main/res/drawable-nodpi/mail.xml create mode 100644 app/src/main/res/drawable-nodpi/menu.xml create mode 100644 app/src/main/res/drawable-nodpi/menu_book.xml create mode 100644 app/src/main/res/drawable-nodpi/more_vert.xml create mode 100644 app/src/main/res/drawable-nodpi/my_location.xml create mode 100644 app/src/main/res/drawable-nodpi/navigate_before.xml create mode 100644 app/src/main/res/drawable-nodpi/navigate_next.xml create mode 100644 app/src/main/res/drawable-nodpi/open_in_new.xml create mode 100644 app/src/main/res/drawable-nodpi/phone_android.xml create mode 100644 app/src/main/res/drawable-nodpi/play_arrow.xml create mode 100644 app/src/main/res/drawable-nodpi/policy.xml create mode 100644 app/src/main/res/drawable-nodpi/psychology.xml create mode 100644 app/src/main/res/drawable-nodpi/push_pin.xml create mode 100644 app/src/main/res/drawable-nodpi/redo.xml create mode 100644 app/src/main/res/drawable-nodpi/refresh.xml create mode 100644 app/src/main/res/drawable-nodpi/remove.xml create mode 100644 app/src/main/res/drawable-nodpi/restore.xml create mode 100644 app/src/main/res/drawable-nodpi/save.xml create mode 100644 app/src/main/res/drawable-nodpi/screen_rotation.xml create mode 100644 app/src/main/res/drawable-nodpi/settings.xml create mode 100644 app/src/main/res/drawable-nodpi/settings_backup_restore.xml create mode 100644 app/src/main/res/drawable-nodpi/share.xml create mode 100644 app/src/main/res/drawable-nodpi/smartphone.xml create mode 100644 app/src/main/res/drawable-nodpi/star.xml create mode 100644 app/src/main/res/drawable-nodpi/stop.xml create mode 100644 app/src/main/res/drawable-nodpi/swap_horiz.xml create mode 100644 app/src/main/res/drawable-nodpi/text_fields.xml create mode 100644 app/src/main/res/drawable-nodpi/text_select_start.xml create mode 100644 app/src/main/res/drawable-nodpi/touch_app.xml create mode 100644 app/src/main/res/drawable-nodpi/tune.xml create mode 100644 app/src/main/res/drawable-nodpi/upload_file.xml create mode 100644 app/src/main/res/drawable-nodpi/verified.xml create mode 100644 app/src/main/res/drawable-nodpi/verified_user.xml create mode 100644 app/src/main/res/drawable-nodpi/visibility.xml create mode 100644 app/src/main/res/drawable-nodpi/visibility_off.xml create mode 100644 app/src/main/res/drawable-nodpi/volume_up.xml create mode 100644 app/src/proTest/java/com/aryan/reader/data/FirestoreRepositoryMappingTest.kt create mode 100644 app/src/proTest/java/com/aryan/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt create mode 100644 app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt create mode 100644 app/src/test/java/com/aryan/reader/BookReplacementHtmlTest.kt create mode 100644 app/src/test/java/com/aryan/reader/CloudEpubAnnotationMetadataTest.kt create mode 100644 app/src/test/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisionsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epub/EpubImportSecurityTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/ChapterWebViewHighlightJsonTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsStateTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PaginatorMeasurementContractTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/ReaderLinkAnnotationTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/tts/TtsCacheManagerSecurityTest.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAtomicFile.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncDiagnostics.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacement.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncFeedback.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbing.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSyncSidecars.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProfileAvatar.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDefaults.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpenTrace.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTexturePreview.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowsWebView2EpubWebView.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAuthStoreTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudConfigTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsingTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubPaginationTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacementTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabaseTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPaidAiUsageTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationSidebarTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbingTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarsTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfTextHighlightStateTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderKeyCommandsTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderTypographyTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopTtsLogTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWebView2LayoutTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/LinuxSecretToolCodecTest.kt create mode 100644 shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt create mode 100644 shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt create mode 100644 shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt create mode 100644 shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/CloudSyncDecisions.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderWordReplacements.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLegalLinks.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderTooltips.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderTtsOverlayControls.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/CloudSyncDecisionsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderBookReplacementEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLegalLinksTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderMinimalSliderTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUiTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/paginatedreader/HtmlParserLinkTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt diff --git a/.gitignore b/.gitignore index 31a2c96..81181f9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,7 @@ google-services.json .gradle/ third_party/pdfium/ *.tgz -kcef-bundle/ -kcef-bundle-linux-x64/ cache/ worker/ -output/ \ No newline at end of file +output/ +policies/ \ No newline at end of file diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index b8dcf92..731e355 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -162,6 +162,19 @@ + + + + + + + @@ -309,6 +322,19 @@ + + + + + + + @@ -357,6 +383,7 @@ + @@ -534,6 +561,19 @@ + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d29580..de561f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.ksp) id("com.diffplug.spotless") version "8.2.1" alias(libs.plugins.kover) @@ -243,8 +243,6 @@ dependencies { ksp(libs.androidx.room.compiler) - implementation("androidx.compose.material:material-icons-extended:1.7.8") - implementation("androidx.appcompat:appcompat:1.7.1") //noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break) diff --git a/app/src/androidTest/assets/epub/reader_test_book.epub b/app/src/androidTest/assets/epub/reader_test_book.epub new file mode 100644 index 0000000000000000000000000000000000000000..3858f2664776e6b9b99729c1cdd938c0c4c82af1 GIT binary patch literal 4715 zcmai12RK~Y79K<&CDDlp!Bs;L1kn;<5JqpKg~4DLW=0RuqeY@6qD1e6=p|~@h)#5g z(MyIXiT)G@+GJ4DC+>!%`I?-*zFIJ>W;s~`x{xF=-g z?1Zv}IYC{od%zt{pR|y*3RB<3o`jbgL+Z{Rkw^7y~G2$e`_1p5}9BM zdlyxceywjQDiKRE60EH!6l221^L9v6f0J5~>4v$0Ud}))BI@ zvqYevuE@VSg2Fehd)T4ij&Bq0JI@PKhhR_W%4MHt?bg$QwNy1XaRr z)=BthzMY^ug#~opMwIOPk)k`j>tS~=HgCaO;bWl8q@pXVH{?@Zs;?QvM#UUWa@E6F z#eAptq`O}!Fa(+NybNU-$eg^HxFy^41T$(+SFFKdW$SXXw#H``dJ{-X^$c8Idh8|b zzzPt%@Kpe4w~}ozzDAPjT`}-+{IQzOs=juAONPD3vIJR*{4VgAyOVpTX5VG)GJ&Pb zq_r09^N0s(+k;~=Qf$G5F3)b342g4dPc?8MUU#ZStY)Qo%r zR-kR7dM!o*!A}F@)~|LGkxa#d0%=1>v)q>{{nv|LHl+uaajK3y-qD2Ax`<>Idpg;! zI6xKy%Jth8<9PI@2hE;eA7kD%_X52>lhK%L3w084lB2jX6(uPE$LmKfjQQu7Z{ZU> z+Ox>Pk}zLmyoFC0EL4*c>teqWGj?FCq&;B5H)iM~k%K>6dz1lSw^bZmEF|MjXnN_) z=dp2HMs_e>mus4Gs_96N^lp0#>7DVZJg_XYLO*eniLpb1R2z^x>1x5qw3CI>HHQ87 z>0ax+3Q(~0&`G>o%Sx%5`KMeRBmY@U?hxkRNRPRNkLB&0rA*cc-e`&DFkjDH)A|exk9;J)%!MN1Pf?+m zQ>6QG;qBuIR;O3luYve$5YeHM>K1Tz(W2|L^<|`6E9n3$9vbKi)kiCWJ8s+_zEprku|jQi}3{v@I(xndxCoi zKHde^%V1jU0&;1kaV87Y+0MDnlaTaP5hkM{O&qng1F&l=w;ta)d@MSV? z=94f7%@tGfGBMQ`LMXPF&Z%ATS}gi??A)pVNFq|OLv@0~>F^d+ebs=WfT08bjNd&L z6rDSRI)NZW%oJO$X*T~`N0NJ99#eVS+wCTkk+IAaA`qP*T|C`Jlmg$j{<58BQiPI` zLNMA0t!!fKANukp5hW*if<555wl&(+{OE6JD=RDA7@Q>$;|aZ)P$# z>-_M3hUyf5>w7!=Ifep0ft>B}H`N}Kgm+h$7!cEl{QD%O0(i;Vz9>A2g!JA^8eQ;!E z{|28lNY+$SxxL?rGRlP4_((3TxcI&i2IAg*U{f9@v0Ns8lMMoaY!!D3gIh%{-^(0- zt?AJK#1JVhvaJk)a)nuLW+3g-?0^qikDlL_@7^Z`7Q`7<>)5f}?QGtZQcr9v#$;Cl z?dC+FF1_B=5?PP9UF}Ra@MKoL1|q)Z;VNSDgH@gjPxX3wW|bEjsKy8y=2~8jb|JNl zH!2U*7^{liIKcF+BP#-{lusb0X_u723-~+*mBw!b%2Mpobz8NY9|l9)x~+|Y5%sIq zTEJxq&$61t*=U+CWlSn++lFHOhArAGTenRRQHWEYMOWgeA{He%VqI?2pgy(+G-{qC zLu}HR+H~1dWq3mJ^9)PdqV|OD_jNS2hH@@C44HVz5t(<7;4JYCy%H9p3ByK#b*`8E=uIN8ZORgyE z{T)9bGH&7Qq9wdrLkPP=PDZPwMzFx!L-aoP<1+#1{eYG+g@%|6@dxN+CdF<&im^CL z0fPKR=Yg_^B}^oX+TvgM5uZK_Jyt2nGPE;%b2Ol`!Z*Gfaz_q@@`9@Zsq8mftPk4z z0^Mhm{Ja}p-}B~;&uCx$^bHY;ze~6hw0*svNQ2m7x2{-VrILL0i;0hO(Cnz>ks)To z?`EIenBum#n|U(?bK#`lo+Fe#@g{E+2wNeH6s z6#rK)Zyf38K=1$nNgSWA{(U}y!7Xi}NFf`T2MX;96|{y~+PYf8uOr=T^$a2V!~-*AP`*-Q7RdLJ`YNTxncKzDF9_%$>e?OYz>FEaneKknRufLGAe zvRVkz_5;DQyse{+&YAby{*d6kPAwNStOHrhY>l<{S%p;Cni=RTe5Xyax{FI zcbQDL<&(NrV$!BacztgZlYjHeNVFCGu=EXFg(k1u!Zw%(MG&%hv`wW_dC8Kf7#P@O zv`b(on=;3VY3h?d>?5oFOoqL}a^=#G2D(>>e~rv+Ioih$7a30i0D$W6$T(TL{jsaW z$7*S|3eyJ9ozPXvmWz0R6H4xC(a_LDJ|Si!uP+TAyuKOV(o{Xe$aASZcV;%l_qKF3 zrtL$)RR>}5k9;|n1KAfw)N^Z%-=Sh;+?-axkgfMe+dQ6ro>!Sl$g20L=BxNvhJj^~ zjD-b!d58Yukg3$mwn@bjyJ=s-bH%hLPuiy^G1C4}hnarW2@jf+*f3 zqHS%7mWl%sU(P5H1CLGgO)QFuWk*-tlnq?(A6F|$r=@DeJm#$2UkNBSeNyt2FRplqgiO^GIKy3WRkG~qMcF<)6)1>*?s}B zs%I?IazxUfN&CZ`+1hS7WPa}D@{w|IR~U*HQ=8)SHN4`A4O^~S55S(#A2XD)VO2Ab z%4aW$>702eQa`^cUmBBS+hf}${WVrMkGwkOaj`17003P4J5~rwD+gR2U3W&<6vt^s zCka!xV~=o(5@h@+ez3DC&8YIGcu3!#i>>rz7CRwI zzlh1lj^ibFw&=>JY4P$!{Jygf@>z)9y93kPDR666f%~+7V;@)S!EC=stAdNv zr1TE&23~f2|GMTw5DSGbn?f29O{o%s&W|a?!1xnQ$;t>VVaP|S$oShAvh~17?erwI z7p$fXfv9WgfvE^H8xVKqv!XiQ5_aI;&D<*~yd4+OpXFf)MW#)nkq37#HWTBy`S32X zG}AE)qriSq8v8J}H_JkfY8v~A92rP`Q*{ci;FjX5yoaqijI7`D%n2Dfbwi)oLjuDB z-uvjQ2V0I&&_g5*AQE^GY(%QDQfIdG671A0izy2JlHSFKf6+Q3INkxeEW#fsm;&>+ zdL*mgn@3>lO$`|$@=b4d5Ezx zQ=gWa9E=b1e|?i;{_5&{U%qTu_|&7k*a{!Q;lf%^p6u$NMp8~0=&V_Q7l)rv*BR${ z(V&_!@AFrhCtC!+5`wo>qdFc(gc+P&{+kd;l&2$ZTXlt6T0>n0QBWl6x)l+;Rq!r?ZfyeiXB-fQ~U4{9nD0U zBP-#8=#bbl9`j)I#{2PU2m}Ay?W?SZJqh6rsx-lP)`>*7yLy7M4Scy5*lmhZpLAQy zQ^Gxkl%^Qmqn-e#rS_hVUty((0)tnbXO2YG7A^%I@Rg@1p;1f1zF;*xeA?gjJnrjp zI{m!D|MB!k%fhk#r{4eR_0Q`5$J`$+3r`An|7Wd#9^<@%{vF_V3})QB|Bs$N4|ZN| z`~;K2rS89h{V6`qgPlJI{sj9%^zUGQo(j){oiD^c!Rko=0{i!}d>-w5@%V|ROa2$y z-=*Zd^ZDKEr!#=!FX#W-+s?b6=l)Oki?}-W$3On(OaQA9;(i None: + info = ZipInfo(archive_name, FIXED_TIMESTAMP) + info.compress_type = compression + info.external_attr = 0o644 << 16 + epub.writestr(info, source.read_bytes()) + + +def main() -> None: + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + + with ZipFile(OUTPUT, "w") as epub: + info = ZipInfo("mimetype", FIXED_TIMESTAMP) + info.compress_type = ZIP_STORED + info.external_attr = 0o644 << 16 + epub.writestr(info, b"application/epub+zip") + + for source in sorted(SOURCE_DIR.rglob("*")): + if not source.is_file() or source.name == "mimetype": + continue + archive_name = source.relative_to(SOURCE_DIR).as_posix() + add_file(epub, source, archive_name, ZIP_DEFLATED) + + print(f"Wrote {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml new file mode 100644 index 0000000..fe5cbeb --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml new file mode 100644 index 0000000..d17d58f --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml @@ -0,0 +1,26 @@ + + + + + Chapter One + + + +
+

Chapter One: Stable Opening

+

This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.

+

POSITION_TARGET_ALPHA appears near the start of chapter one. Use this marker for first-position and restore-position checks.

+

HIGHLIGHT_TARGET_BRAVO is a short highlight target. It is surrounded by ordinary words so selection handles have context.

+

CFI_TARGET_CHARLIE sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.

+

Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.

+

Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.

+

END_OF_CHAPTER_ONE_MARKER

+
+ + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml new file mode 100644 index 0000000..c5acd90 --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml @@ -0,0 +1,25 @@ + + + + + Chapter Two + + + +
+

Chapter Two: Search And Bookmarks

+

SEARCH_TARGET_DELTA appears exactly once in the book. Use it for search result navigation.

+

BOOKMARK_TARGET_ECHO is positioned near the top of chapter two for bookmark add, list, and return flows.

+

POSITION_TARGET_FOXTROT is lower in chapter two and is useful for persistence tests after a chapter change.

+

Chapter two filler paragraph 01 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 02 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 03 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 04 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 05 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 06 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 07 provides enough height for gesture based navigation.

+

Chapter two filler paragraph 08 provides enough height for gesture based navigation.

+

END_OF_CHAPTER_TWO_MARKER

+
+ + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml new file mode 100644 index 0000000..73b68a0 --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml @@ -0,0 +1,24 @@ + + + + + Chapter Three + + + +
+

Chapter Three: Annotation Targets

+

ANNOTATION_TARGET_GOLF is reserved for tests that verify highlight or note persistence across app restarts.

+

CFI_TARGET_HOTEL is a second fixed CFI target in a later chapter.

+

The following SVG is local to the EPUB and can be used later for image rendering checks.

+
+ Fixture diagram +
Local SVG image for EPUB resource resolution.
+
+

Chapter three filler paragraph 01 rounds out the fixture.

+

Chapter three filler paragraph 02 rounds out the fixture.

+

Chapter three filler paragraph 03 rounds out the fixture.

+

END_OF_CHAPTER_THREE_MARKER

+
+ + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg new file mode 100644 index 0000000..730dbff --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg @@ -0,0 +1,9 @@ + + + Fixture diagram + A simple local SVG used by EPUB UI tests. + + + + EPUB FIXTURE + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml new file mode 100644 index 0000000..14a8470 --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml @@ -0,0 +1,24 @@ + + + + + Reader Android UI Test Book + + + + + + + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf new file mode 100644 index 0000000..7bdfd8f --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf @@ -0,0 +1,23 @@ + + + + urn:uuid:reader-android-ui-test-epub + Reader Android UI Test Book + Reader Test Fixtures + en + 2026-01-01T00:00:00Z + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css new file mode 100644 index 0000000..5799725 --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css @@ -0,0 +1,21 @@ +body { + font-family: serif; + line-height: 1.5; +} + +h1 { + font-size: 1.4em; +} + +p { + margin: 0 0 1em 0; +} + +.fixture-note { + border-left: 0.25em solid #4a90e2; + padding-left: 0.75em; +} + +.target { + font-weight: bold; +} diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/mimetype b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype new file mode 100644 index 0000000..403c4f0 --- /dev/null +++ b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype @@ -0,0 +1 @@ +application/epub+zip diff --git a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt index d7fd42a..e966b22 100644 --- a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt @@ -1,146 +1,117 @@ -// AppNavigationTest.kt package com.aryan.reader -import android.net.Uri -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.navigation.compose.ComposeNavigator -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.aryan.reader.epub.EpubBook -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AppNavigationTest { - @get:Rule - val composeTestRule = createComposeRule() + @Test + fun appDestinations_useStableReaderRoutes() { + assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main") + assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer") + assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader") + } - private lateinit var navController: TestNavHostController - private val fakeUiState = MutableStateFlow(ReaderScreenState()) + @Test + fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() { + val mappedSurfaces = listOf( + FileType.PDF, + FileType.CBZ, + FileType.CBR, + FileType.CB7, + FileType.CBT, + FileType.PPTX + ).associateWith { it.readerSurfaceOnAndroid() } - // Mock ViewModel that uses the fake state - private val fakeViewModel: MainViewModel = object : MainViewModel( - ApplicationProvider.getApplicationContext() - ) { - override val uiState = fakeUiState - override fun clearSelectedFile() { - fakeUiState.value = fakeUiState.value.copy( - selectedFileType = null, - selectedPdfUri = null, - selectedEpubBook = null + assertThat(mappedSurfaces).containsExactly( + FileType.PDF, ReaderFeatureSurface.PDF_VIEWER, + FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER, + FileType.CBR, ReaderFeatureSurface.PDF_VIEWER, + FileType.CB7, ReaderFeatureSurface.PDF_VIEWER, + FileType.CBT, ReaderFeatureSurface.PDF_VIEWER, + FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER + ) + } + + @Test + fun androidReaderSurface_mapsTextBackedTypesToEpubReader() { + val mappedSurfaces = listOf( + FileType.EPUB, + FileType.MOBI, + FileType.MD, + FileType.TXT, + FileType.HTML, + FileType.FB2, + FileType.DOCX, + FileType.ODT, + FileType.FODT + ).associateWith { it.readerSurfaceOnAndroid() } + + assertThat(mappedSurfaces).containsExactly( + FileType.EPUB, ReaderFeatureSurface.EPUB_READER, + FileType.MOBI, ReaderFeatureSurface.EPUB_READER, + FileType.MD, ReaderFeatureSurface.EPUB_READER, + FileType.TXT, ReaderFeatureSurface.EPUB_READER, + FileType.HTML, ReaderFeatureSurface.EPUB_READER, + FileType.FB2, ReaderFeatureSurface.EPUB_READER, + FileType.DOCX, ReaderFeatureSurface.EPUB_READER, + FileType.ODT, ReaderFeatureSurface.EPUB_READER, + FileType.FODT, ReaderFeatureSurface.EPUB_READER + ) + } + + @Test + fun androidReaderSurface_returnsNullForUnknownFileType() { + assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull() + } + + @Test + fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() { + assertThat( + shouldInterceptAppNavBack( + currentRoute = AppDestinations.PRO_SCREEN_ROUTE, + hasPreviousBackStackEntry = true, + isCurrentEntryResumed = true ) - } - } - - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) - @Before - fun setup() { - composeTestRule.setContent { - navController = TestNavHostController(LocalContext.current) - navController.navigatorProvider.addNavigator(ComposeNavigator()) - AppNavigation( - navController = navController, - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)), - viewModel = fakeViewModel + ).isTrue() + assertThat( + shouldInterceptAppNavBack( + currentRoute = AppDestinations.MAIN_ROUTE, + hasPreviousBackStackEntry = true, + isCurrentEntryResumed = true ) - } - } - - @Test - fun appNavigation_defaultStartDestination_isMainRoute() { - val currentRoute = navController.currentBackStackEntry?.destination?.route - assertEquals(AppDestinations.MAIN_ROUTE, currentRoute) - } - - @Test - fun appNavigation_whenPdfSelected_navigatesToPdfViewer() { - // Trigger state change - fakeUiState.value = ReaderScreenState( - selectedFileType = FileType.PDF, - selectedPdfUri = Uri.parse("content://dummy.pdf") - ) - - // Let compose recompose and run LaunchedEffect - composeTestRule.waitForIdle() - - val currentRoute = navController.currentBackStackEntry?.destination?.route - 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 - fakeUiState.value = ReaderScreenState( - selectedFileType = FileType.EPUB, - selectedEpubBook = EpubBook( - fileName = "dummy.epub", - title = "Dummy Book", - author = "Author", - language = "en", - coverImage = null + ).isFalse() + assertThat( + shouldInterceptAppNavBack( + currentRoute = AppDestinations.PDF_VIEWER_ROUTE, + hasPreviousBackStackEntry = true, + isCurrentEntryResumed = true ) - ) - - composeTestRule.waitForIdle() - - val currentRoute = navController.currentBackStackEntry?.destination?.route - assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute) + ).isFalse() + assertThat( + shouldInterceptAppNavBack( + currentRoute = AppDestinations.PRO_SCREEN_ROUTE, + hasPreviousBackStackEntry = false, + isCurrentEntryResumed = true + ) + ).isFalse() + assertThat( + shouldInterceptAppNavBack( + currentRoute = AppDestinations.PRO_SCREEN_ROUTE, + hasPreviousBackStackEntry = true, + isCurrentEntryResumed = false + ) + ).isFalse() } - @Test - fun appNavigation_whenFileCleared_navigatesBackToMain() { - // First, navigate to PDF viewer - fakeUiState.value = ReaderScreenState( - selectedFileType = FileType.PDF, - selectedPdfUri = Uri.parse("content://dummy.pdf") - ) - composeTestRule.waitForIdle() - assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route) - - // Then, trigger the clear action (simulating onNavigateBack) - fakeViewModel.clearSelectedFile() - composeTestRule.waitForIdle() - - val currentRoute = navController.currentBackStackEntry?.destination?.route - assertEquals(AppDestinations.MAIN_ROUTE, currentRoute) - } - - @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) + private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? { + return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID) } } diff --git a/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt b/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt new file mode 100644 index 0000000..5422300 --- /dev/null +++ b/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt @@ -0,0 +1,126 @@ +package com.aryan.reader + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.aryan.reader.data.RecentFileItem +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeRecentFileCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun recentFileCardShowsProgressAndUnavailableState() { + val item = recentBook( + bookId = "home_unavailable_epub", + title = "Unavailable Field Guide", + author = "Casey Example", + progress = 42f, + isAvailable = false + ) + + setRecentFileCard(item = item) + + composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed() + composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed() + composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed() + composeTestRule.onNodeWithText("42%").assertIsDisplayed() + composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0] + .assertIsDisplayed() + } + + @Test + fun recentFileCardClickLongClickAndSelectedOverlayWork() { + val item = recentBook( + bookId = "home_selected_pdf", + title = "Selected Position Notes", + author = "Morgan Example", + progress = 7f + ) + var clicked = false + var longClicked = false + + setRecentFileCard( + item = item, + isSelected = true, + onClick = { clicked = true }, + onLongClick = { longClicked = true } + ) + + composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0] + .assertIsDisplayed() + + composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick() + composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput { + down(center) + advanceEventTime(600) + up() + } + + assertThat(clicked).isTrue() + assertThat(longClicked).isTrue() + } + + private fun setRecentFileCard( + item: RecentFileItem, + isSelected: Boolean = false, + isPinned: Boolean = false, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {} + ) { + composeTestRule.setContent { + MaterialTheme { + RecentFileCard( + item = item, + isSelected = isSelected, + isPinned = isPinned, + onClick = onClick, + onLongClick = onLongClick, + isDownloading = false, + usePdfFileNameAsDisplayName = false + ) + } + } + } + + private fun recentBook( + bookId: String, + title: String, + author: String, + progress: Float, + isAvailable: Boolean = true + ): RecentFileItem { + return RecentFileItem( + bookId = bookId, + uriString = "content://home-test/$bookId", + type = FileType.EPUB, + displayName = "$bookId.epub", + timestamp = 1_000L, + title = title, + author = author, + progressPercentage = progress, + isRecent = true, + isAvailable = isAvailable + ) + } + + private fun text(resId: Int): String { + return context.getString(resId) + } +} diff --git a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt new file mode 100644 index 0000000..a674c3f --- /dev/null +++ b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt @@ -0,0 +1,398 @@ +package com.aryan.reader + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalFoundationApi::class) +@RunWith(AndroidJUnit4::class) +class LibraryScreenContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L) + private val libraryBooks = listOf( + libraryBook( + bookId = "pdf_beta", + type = FileType.PDF, + displayName = "beta.pdf", + title = "Beta Manual", + author = "Mira Example", + timestamp = 3_000L, + progress = 84f + ), + libraryBook( + bookId = "epub_gamma", + type = FileType.EPUB, + displayName = "gamma.epub", + title = "Gamma Field Notes", + author = "Nora Example", + timestamp = 2_000L, + progress = 47f, + tags = listOf(focusTag) + ), + libraryBook( + bookId = "epub_alpha", + type = FileType.EPUB, + displayName = "alpha.epub", + title = "Alpha Orchard", + author = "Zara Example", + timestamp = 1_000L, + progress = 12f, + tags = listOf(focusTag) + ) + ) + + @Test + fun searchFiltersAndClearRestoresLibraryList() { + setLibraryContent() + + composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick() + composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma") + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty() + } + assertNoText("Alpha Orchard") + assertNoText("Beta Manual") + + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick() + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick() + composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed() + } + + @Test + fun searchMatchesAuthorAndTagNames() { + setLibraryContent() + + composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick() + composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara") + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() + } + assertNoText("Gamma Field Notes") + + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick() + composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus") + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty() + } + assertNoText("Beta Manual") + } + + @Test + fun activeFileTypeFilterChipCanBeCleared() { + setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB))) + + composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed() + composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed() + assertNoText("Beta Manual") + + composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty() + } + } + + @Test + fun tagAndReadStatusFiltersUseSharedLibraryRules() { + setLibraryContent( + initialFilters = LibraryFilters( + tagIds = setOf(focusTag.id), + readStatus = ReadStatusFilter.IN_PROGRESS + ) + ) + + composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed() + composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed() + assertNoText("Beta Manual") + composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed() + composeTestRule.onNodeWithText( + text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes)) + ).assertIsDisplayed() + } + + @Test + fun clearSelectionReturnsToNormalToolbar() { + setLibraryContent() + + composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput { + down(center) + advanceEventTime(600) + up() + } + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty() + } + } + + @Test + fun sortMenuSelectionReordersLibraryItems() { + setLibraryContent() + + assertBookAbove("pdf_beta", "epub_alpha") + + composeTestRule.onNodeWithTag("LibrarySortButton").performClick() + composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick() + + composeTestRule.waitUntil(5_000) { + runCatching { + bookTop("epub_alpha") < bookTop("pdf_beta") + }.getOrDefault(false) + } + assertBookAbove("epub_alpha", "pdf_beta") + } + + @Test + fun longPressBookShowsContextualToolbarActions() { + var tagClicked = false + var pinClicked = false + var infoClicked = false + var selectAllClicked = false + var deleteClicked = false + + setLibraryContent( + onTagClick = { tagClicked = true }, + onPinClick = { pinClicked = true }, + onInfoClick = { infoClicked = true }, + onSelectAllClick = { selectAllClicked = true }, + onDeleteClick = { deleteClicked = true } + ) + + composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput { + down(center) + advanceEventTime(600) + up() + } + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick() + + assertThat(tagClicked).isTrue() + assertThat(pinClicked).isTrue() + assertThat(infoClicked).isTrue() + assertThat(selectAllClicked).isTrue() + assertThat(deleteClicked).isTrue() + } + + @Test + fun shelvesTabShowsShelfRowsAndNewShelfAction() { + val shelf = Shelf( + id = "manual_favorites", + name = "Manual Favorites", + type = ShelfType.MANUAL, + books = listOf(libraryBooks[0], libraryBooks[1]) + ) + var clickedShelfId: String? = null + var longClickedShelfId: String? = null + var newShelfClicked = false + + setLibraryContent( + initialPage = 1, + shelves = listOf(shelf), + onShelfClick = { clickedShelfId = it.id }, + onShelfLongClick = { longClickedShelfId = it.id }, + onNewShelfClick = { newShelfClicked = true } + ) + + composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed() + composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed() + + composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick() + composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput { + down(center) + advanceEventTime(600) + up() + } + composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick() + + assertThat(clickedShelfId).isEqualTo("manual_favorites") + assertThat(longClickedShelfId).isEqualTo("manual_favorites") + assertThat(newShelfClicked).isTrue() + } + + private fun setLibraryContent( + initialFilters: LibraryFilters = LibraryFilters(), + initialPage: Int = 0, + shelves: List = emptyList(), + onTagClick: () -> Unit = {}, + onPinClick: () -> Unit = {}, + onInfoClick: () -> Unit = {}, + onSelectAllClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + onShelfClick: (Shelf) -> Unit = {}, + onShelfLongClick: (Shelf) -> Unit = {}, + onNewShelfClick: () -> Unit = {} + ) { + val searchQuery = mutableStateOf("") + val isSearchActive = mutableStateOf(false) + val filters = mutableStateOf(initialFilters) + val sortOrder = mutableStateOf(SortOrder.RECENT) + val selectedItems = mutableStateOf(emptySet()) + + composeTestRule.setContent { + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { 3 } + ) + val visibleBooks = sortFiles( + applyLibraryFilters( + filterBySearch(libraryBooks, searchQuery.value), + filters.value + ), + sortOrder.value + ) + + MaterialTheme { + LibraryScreenContent( + tabTitles = listOf( + text(R.string.tab_all_books), + text(R.string.tab_shelves), + text(R.string.tab_folders) + ), + recentFiles = visibleBooks, + rawLibraryFiles = libraryBooks, + shelves = shelves, + selectedItems = selectedItems.value, + selectedShelves = emptySet(), + sortOrder = sortOrder.value, + libraryFilters = filters.value, + allTags = listOf(focusTag), + pinnedLibraryBookIds = emptySet(), + pagerState = pagerState, + scope = rememberCoroutineScope(), + searchQuery = searchQuery.value, + isSearchActive = isSearchActive.value, + onSearchQueryChange = { searchQuery.value = it }, + onSearchActiveChange = { isSearchActive.value = it }, + onSortOrderChange = { sortOrder.value = it }, + onFilterClick = {}, + onClearFilters = { filters.value = LibraryFilters() }, + onRemoveFilter = { filters.value = it }, + onTagClick = onTagClick, + onPinClick = onPinClick, + onClearSelection = { selectedItems.value = emptySet() }, + onItemClick = {}, + onItemLongClick = { item -> selectedItems.value = setOf(item) }, + onInfoClick = onInfoClick, + onDeleteClick = onDeleteClick, + onSelectAllClick = onSelectAllClick, + onShelfClick = onShelfClick, + onShelfLongClick = onShelfLongClick, + onClearShelfSelection = {}, + onDeleteShelves = {}, + onNewShelfClick = onNewShelfClick, + onSelectFileClick = {}, + onScanNowClick = {}, + onSyncMetadataClick = {}, + onSelectSyncFolderClick = {}, + onEditFolderFiltersClick = { _, _ -> }, + onDisconnectSyncFolderClick = {}, + downloadingBookIds = emptySet(), + lastFolderScanTime = null, + isLoading = false, + isRefreshing = false, + syncedFolders = emptyList(), + onRemoveFolderClick = {}, + onFolderLocalSyncChange = { _, _, _ -> }, + onOpdsBookDownloaded = { _, _ -> }, + onStreamOpdsBook = { _, _ -> }, + onDeleteCatalogStreams = {}, + onSettingsClick = {}, + usePdfFileNameAsDisplayName = false + ) + } + } + } + + private fun libraryBook( + bookId: String, + type: FileType, + displayName: String, + title: String, + author: String, + timestamp: Long, + progress: Float, + tags: List = emptyList() + ): RecentFileItem { + return RecentFileItem( + bookId = bookId, + uriString = "content://library-test/$bookId", + type = type, + displayName = displayName, + timestamp = timestamp, + title = title, + author = author, + progressPercentage = progress, + isRecent = true, + isAvailable = true, + fileSize = timestamp * 10, + tags = tags + ) + } + + private fun assertBookAbove(upperBookId: String, lowerBookId: String) { + assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId)) + } + + private fun assertNoText(value: String) { + assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty() + } + + private fun bookTop(bookId: String): Float { + return composeTestRule + .onNodeWithTag("LibraryBookItem_$bookId") + .fetchSemanticsNode() + .boundsInRoot + .top + } + + private fun text(resId: Int, vararg args: Any): String { + return context.getString(resId, *args) + } +} diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt index cf9996f..68e6283 100644 --- a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt @@ -1,13 +1,19 @@ package com.aryan.reader.epubreader import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.json.JSONArray import org.json.JSONObject import org.junit.Rule import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class ChapterWebViewBridgeTest { @@ -20,7 +26,8 @@ class ChapterWebViewBridgeTest { var receivedCfi = "" val bridge = CfiJsBridge( onCfiReady = { cfi -> receivedCfi = cfi }, - onCfiForBookmarkReady = {} + onCfiForBookmarkReady = {}, + onScrollFinishedCallback = {} ) val cfi = "/4/2[chapter1]/6:10" @@ -39,7 +46,8 @@ class ChapterWebViewBridgeTest { var receivedCfi = "" val bridge = CfiJsBridge( onCfiReady = { cfi -> receivedCfi = cfi }, - onCfiForBookmarkReady = {} + onCfiForBookmarkReady = {}, + onScrollFinishedCallback = {} ) val invalidJson = "this is not json" @@ -54,7 +62,8 @@ class ChapterWebViewBridgeTest { var receivedCfi: String? = null val bridge = CfiJsBridge( onCfiReady = { cfi -> receivedCfi = cfi }, - onCfiForBookmarkReady = {} + onCfiForBookmarkReady = {}, + onScrollFinishedCallback = {} ) val jsonResponse = JSONObject().apply { @@ -69,29 +78,51 @@ class ChapterWebViewBridgeTest { } @Test - fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest { + fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() { + val latch = CountDownLatch(1) var receivedJson: String? = null - val bridge = TtsJsBridge( - scope = this, - ttsStructuredTextHandler = { json -> receivedJson = json } - ) - val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]" - bridge.onStructuredTextExtracted(jsonPayload) - advanceUntilIdle() - assertThat(receivedJson).isEqualTo(jsonPayload) + val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val bridge = TtsJsBridge( + scope = bridgeScope, + ttsStructuredTextHandler = { json -> + receivedJson = json + latch.countDown() + } + ) + val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]" + + bridge.onStructuredTextExtracted(jsonPayload) + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(receivedJson).isEqualTo(jsonPayload) + } finally { + bridgeScope.cancel() + } } @Test - fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest { + fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() { + val latch = CountDownLatch(1) var receivedJson: String? = null - val bridge = TtsJsBridge( - scope = this, - ttsStructuredTextHandler = { json -> receivedJson = json } - ) - val jsonPayload = "" - bridge.onStructuredTextExtracted(jsonPayload) - advanceUntilIdle() - assertThat(receivedJson).isEqualTo("[]") + val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val bridge = TtsJsBridge( + scope = bridgeScope, + ttsStructuredTextHandler = { json -> + receivedJson = json + latch.countDown() + } + ) + val jsonPayload = "" + + bridge.onStructuredTextExtracted(jsonPayload) + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(receivedJson).isEqualTo("[]") + } finally { + bridgeScope.cancel() + } } @Test @@ -153,4 +184,4 @@ class ChapterWebViewBridgeTest { advanceUntilIdle() assertThat(receivedContent).isEqualTo(content) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt index a310b2d..28d8d05 100644 --- a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt @@ -1,50 +1,43 @@ -// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt package com.aryan.reader.epubreader import android.content.Context -import timber.log.Timber -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.aryan.reader.SearchResult import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubChapter import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.jsoup.Jsoup +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.io.File -import kotlin.math.max -import kotlin.math.min @RunWith(AndroidJUnit4::class) class EpubReaderLogicTest { private lateinit var context: Context private lateinit var testDir: File - private lateinit var mockEpubBook: EpubBook + private lateinit var testBook: EpubBook @Before fun setup() { context = ApplicationProvider.getApplicationContext() - testDir = File(context.cacheDir, "test_epub").apply { mkdirs() } + testDir = File(context.cacheDir, "test_epub_search").apply { + deleteRecursively() + mkdirs() + } - // Create dummy chapter files - val chapter1File = File(testDir, "chapter1.html") - chapter1File.writeText("

A simple Test case.

") + val chapter1File = File(testDir, "chapter1.html").apply { + writeText("

A simple Test case.

") + } + val chapter2File = File(testDir, "chapter2.html").apply { + writeText("

Another test case here.

The word Test appears twice.

") + } - val chapter2File = File(testDir, "chapter2.html") - chapter2File.writeText("

Another test case here.

The word Test appears twice.

") - - mockEpubBook = EpubBook( + testBook = EpubBook( fileName = "test.epub", title = "Test Book", author = "Tester", @@ -77,86 +70,19 @@ class EpubReaderLogicTest { testDir.deleteRecursively() } - private suspend fun searchEpub(book: EpubBook, query: String): List { - val TAG = "EpubReaderLogicTest" - Timber.d("Starting search for query: '$query'") - return withContext(Dispatchers.IO) { - val results = mutableListOf() - book.chapters.forEachIndexed { chapterIndex, chapter -> - try { - val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}" - Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'") - val htmlFile = File(fullPath) - if (!htmlFile.exists()) { - Timber.e("File does not exist: $fullPath") - return@forEachIndexed - } - - val doc = Jsoup.parse(htmlFile, "UTF-8") - val bodyChildren = doc.body().children().toList() - val chunks = bodyChildren.chunked(20) - - chunks.forEachIndexed { chunkIndex, chunkOfElements -> - val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } - val content = Jsoup.parse(chunkHtml).text() - var lastIndex = -1 - - while (true) { - lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true) - if (lastIndex == -1) break - - Timber.d("Found potential match for '$query' at index $lastIndex.") - val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit() - Timber.d("Is it a word start? -> $isWordStart") - if (isWordStart) { - Timber.d("Match is a word start. Adding to results.") - val snippetStart = max(0, lastIndex - 35) - val snippetEnd = min(content.length, lastIndex + query.length + 35) - val rawSnippet = content.substring(snippetStart, snippetEnd) - val annotatedSnippet = buildAnnotatedString { - append(rawSnippet) - val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart - val highlightEnd = highlightStart + query.length - addStyle( - style = SpanStyle(fontWeight = FontWeight.Bold), - start = highlightStart, - end = highlightEnd - ) - } - results.add( - SearchResult( - locationInSource = chapterIndex, - locationTitle = chapter.title, - snippet = annotatedSnippet, - query = query, - occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex }, - chunkIndex = chunkIndex - ) - ) - } - } - } - } catch (e: Exception) { - Timber.e("Error during search in chapter ${chapter.title}", e) - throw e - } - } - Timber.d("Search finished. Total results found: ${results.size}") - results - } - } - @Test - fun search_findsCorrectResults() = runBlocking { - val results = searchEpub(mockEpubBook, "case") + fun search_findsCorrectResults() = runTest { + val results = createEpubSearcher(testBook)("case") + assertThat(results).hasSize(2) assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1) assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1) } @Test - fun search_isCaseInsensitive() = runBlocking { - val results = searchEpub(mockEpubBook, "test") + fun search_isCaseInsensitive() = runTest { + val results = createEpubSearcher(testBook)("test") + assertThat(results).hasSize(3) assertThat(results[0].locationTitle).isEqualTo("Chapter 1") assertThat(results[1].locationTitle).isEqualTo("Chapter 2") @@ -164,41 +90,19 @@ class EpubReaderLogicTest { } @Test - fun search_noResultsFound() = runBlocking { - val results = searchEpub(mockEpubBook, "nonexistent") + fun search_noResultsFound() = runTest { + val results = createEpubSearcher(testBook)("nonexistent") + assertThat(results).isEmpty() } @Test - fun search_createsCorrectSnippetHighlight() = runBlocking { - val query = "Test" - mockEpubBook.chapters.first() - val content = "A simple Test case." - val annotatedString = buildAnnotatedStringWithHighlight(content, query) + fun search_createsCorrectSnippetHighlight() = runTest { + val result = createEpubSearcher(testBook)("Test").first() + val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) } - val spanStyles = annotatedString.spanStyles - assertThat(spanStyles).hasSize(1) - - val style = spanStyles.first().item - assertThat(style.fontWeight).isEqualTo(FontWeight.Bold) - - val start = spanStyles.first().start - val end = spanStyles.first().end - assertThat(annotatedString.substring(start, end)).isEqualTo(query) + assertThat(boldRanges).hasSize(1) + val highlight = boldRanges.first() + assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test") } - - @Suppress("SameParameterValue") - private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString { - return buildAnnotatedString { - append(content) - val highlightStart = content.indexOf(query, ignoreCase = true) - if (highlightStart != -1) { - addStyle( - style = SpanStyle(fontWeight = FontWeight.Bold), - start = highlightStart, - end = highlightStart + query.length - ) - } - } - } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt new file mode 100644 index 0000000..b4402e6 --- /dev/null +++ b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt @@ -0,0 +1,818 @@ +package com.aryan.reader.epubreader + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.core.content.FileProvider +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.aryan.reader.FileType +import com.aryan.reader.FileHasher +import com.aryan.reader.MainActivity +import com.aryan.reader.R +import com.aryan.reader.RenderMode +import com.aryan.reader.data.AppDatabase +import com.aryan.reader.data.RecentFileEntity +import com.aryan.reader.shared.EpubAnnotationSerializer +import com.aryan.reader.shared.ReaderLocator +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class EpubReaderScreenTest { + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + private val fixtureAssetName = "epub/reader_test_book.epub" + private val fixtureBookTitle = "Reader Android UI Test Book" + private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook" + private val targetContext: Context = ApplicationProvider.getApplicationContext() + private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context + private var currentEpubFile: File? = null + private var scenario: ActivityScenario? = null + private lateinit var fixtureBookId: String + + @Before + fun setup() { + clearReaderPrefs() + fixtureBookId = requireNotNull( + runBlocking { + FileHasher.calculateSha256 { + instrumentationContext.assets.open(fixtureAssetName) + } + } + ) + runBlocking { + AppDatabase.getDatabase(targetContext) + .recentFileDao() + .deleteFilePermanently(listOf(fixtureBookId)) + } + } + + @After + fun tearDown() { + scenario?.close() + currentEpubFile?.let { + if (it.exists()) it.delete() + } + } + + @Test + fun fixtureEpub_opensReaderAndShowsBookTitle() { + launchFixtureReader() + waitForReader() + showReaderChrome() + + waitForText(fixtureBookTitle) + composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed() + assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue() + assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue() + } + + @Test + fun fixtureEpub_recordsInitialReadingPosition() { + launchFixtureReader() + waitForReader() + + waitForRecentFile(timeoutMillis = 30_000) { recentFile -> + recentFile?.lastChapterIndex != null && + recentFile.locatorBlockIndex != null && + recentFile.locatorCharOffset != null && + recentFile.progressPercentage != null + } + + val recentFile = readFixtureRecentFile() + assertThat(recentFile?.lastChapterIndex).isAtLeast(0) + assertThat(recentFile?.locatorBlockIndex).isAtLeast(0) + assertThat(recentFile?.locatorCharOffset).isAtLeast(0) + assertThat(recentFile?.progressPercentage).isAtLeast(0f) + } + + @Test + fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() { + launchFixtureReader { fixtureUri -> + seedFixtureRecentFile( + uriString = fixtureUri.toString(), + chapterIndex = 1, + blockIndex = 1, + charOffset = 0, + progress = 45f + ) + } + waitForReader() + + waitForRecentFile(timeoutMillis = 30_000) { recentFile -> + recentFile?.lastChapterIndex == 1 && + recentFile.locatorBlockIndex == 1 && + recentFile.locatorCharOffset == 0 && + (recentFile.progressPercentage ?: 0f) >= 45f + } + } + + @Test + fun fixtureEpub_drawerShowsFixtureChapters() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + + waitForText(text(R.string.tab_chapters)) + waitForTextContaining("Chapter One") + waitForTextContaining("Chapter Two") + waitForTextContaining("Chapter Three") + } + + @Test + fun fixtureEpub_drawerShowsFixtureImageCatalog() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_images)) + + waitForText("Fixture diagram") + waitForTextContaining("Chapter Three") + assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue() + } + + @Test + fun fixtureEpub_searchFindsUniqueFixtureMarker() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.tooltip_search)) + waitForTag("SearchTextField") + + composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA") + composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA") + + waitForTag("SearchResultItem_1", timeoutMillis = 20_000) + composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed() + } + + @Test + fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() { + launchFixtureReader() + waitForReader() + + navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1) + clickContentDescription(text(R.string.tooltip_close_search)) + + waitForRecentFile(timeoutMillis = 30_000) { recentFile -> + recentFile?.lastChapterIndex == 1 && + recentFile.locatorBlockIndex != null && + recentFile.locatorCharOffset != null && + recentFile.progressPercentage != null + } + + openOverflowMenu() + clickText(text(R.string.menu_change_reading_mode)) + clickText(text(R.string.menu_reading_mode_paginated)) + waitForRenderMode(RenderMode.PAGINATED) + + waitForRecentFile(timeoutMillis = 20_000) { recentFile -> + recentFile?.lastChapterIndex == 1 && + (recentFile.progressPercentage ?: 0f) > 0f + } + + openOverflowMenu() + clickText(text(R.string.menu_change_reading_mode)) + clickText(text(R.string.menu_reading_mode_vertical_webview)) + waitForRenderMode(RenderMode.VERTICAL_SCROLL) + + waitForRecentFile(timeoutMillis = 20_000) { recentFile -> + recentFile?.lastChapterIndex == 1 && + (recentFile.progressPercentage ?: 0f) > 0f + } + } + + @Test + fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() { + launchFixtureReader() + waitForReader() + + openOverflowMenu() + clickText(text(R.string.menu_bookmark_this_page)) + + waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson -> + parseBookmarksJson(bookmarksJson).isNotEmpty() + } + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_bookmarks)) + waitForTextContaining("Chapter One") + assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue() + + clickContentDescription(text(R.string.content_desc_more_options_bookmark)) + clickText(text(R.string.action_delete)) + waitForText(text(R.string.dialog_delete_bookmark)) + clickText(text(R.string.action_delete)) + + waitForText(text(R.string.no_bookmarks_yet)) + waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson -> + parseBookmarksJson(bookmarksJson).isEmpty() + } + } + + @Test + fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() { + seedFixtureBookmark() + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_bookmarks)) + + waitForText("BOOKMARK_TARGET_ECHO") + waitForText("Chapter Two") + + clickContentDescription(text(R.string.content_desc_more_options_bookmark)) + clickText(text(R.string.action_rename)) + waitForText(text(R.string.dialog_rename_bookmark)) + composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark") + clickText(text(R.string.action_save)) + waitForText("Renamed fixture bookmark") + + clickContentDescription(text(R.string.content_desc_more_options_bookmark)) + clickText(text(R.string.action_delete)) + waitForText(text(R.string.dialog_delete_bookmark)) + clickText(text(R.string.action_delete)) + waitForText(text(R.string.no_bookmarks_yet)) + } + + @Test + fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() { + seedFixtureHighlight() + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_annotations)) + + waitForText("ANNOTATION_TARGET_GOLF") + waitForText("Fixture note survives startup") + waitForTextContaining("Chapter Three") + + clickText(text(R.string.filter_with_notes)) + waitForText("ANNOTATION_TARGET_GOLF") + waitForText("Fixture note survives startup") + } + + @Test + fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() { + seedFixtureHighlight() + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_annotations)) + + waitForText("ANNOTATION_TARGET_GOLF") + clickContentDescription(text(R.string.content_desc_options)) + composeTestRule.onNodeWithTag("HighlightColor_blue").performClick() + + waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson -> + parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE + } + + clickContentDescription(text(R.string.content_desc_options)) + clickText(text(R.string.menu_edit_note)) + waitForText(text(R.string.action_save_note)) + composeTestRule.onNode(hasSetTextAction()) + .performTextClearance() + composeTestRule.onNode(hasSetTextAction()) + .performTextInput("Updated fixture note") + clickText(text(R.string.action_save_note)) + + waitForText("Updated fixture note") + waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson -> + parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note" + } + + clickContentDescription(text(R.string.content_desc_options)) + clickText(text(R.string.action_delete)) + waitForText(text(R.string.dialog_delete_highlight)) + clickText(text(R.string.action_delete)) + + waitForText(text(R.string.no_highlights_yet)) + waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson -> + parseHighlightsJson(highlightsJson).isEmpty() + } + } + + @Test + fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() { + launchFixtureReader() + waitForReader() + + openOverflowMenu() + clickText(text(R.string.menu_change_reading_mode)) + clickText(text(R.string.menu_reading_mode_paginated)) + waitForRenderMode(RenderMode.PAGINATED) + composeTestRule.waitForIdle() + + openOverflowMenu() + clickText(text(R.string.menu_tap_to_turn_pages)) + assertReaderSettingEventually( + prefsName = "epub_reader_settings", + key = "tap_to_navigate_enabled", + expected = true + ) + + openOverflowMenu() + clickText(text(R.string.menu_realistic_page_turns)) + assertReaderSettingEventually( + prefsName = "reader_prefs", + key = "page_turn_animation_enabled", + expected = true + ) + + openOverflowMenu() + clickText(text(R.string.menu_keep_screen_on)) + assertReaderSettingEventually( + prefsName = "reader_prefs", + key = "keep_screen_on_enabled", + expected = true + ) + } + + @Test + fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() { + launchFixtureReader() + waitForReader() + + openOverflowMenu() + clickText(text(R.string.menu_visual_options)) + + waitForText(text(R.string.visual_options_system_ui)) + waitForText(text(R.string.visual_options_progress_bar)) + waitForText(text(R.string.visual_options_progress_bar_position)) + clickText(text(R.string.label_top)) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) + .getInt("reader_page_info_position", 0) == 1 + } + } + + @Test + fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_text_formatting)) + waitForText(text(R.string.section_font_alignment)) + waitForText(text(R.string.section_layout_spacing)) + waitForText(text(R.string.label_font_size)) + waitForText(text(R.string.label_line_height)) + waitForText(text(R.string.label_paragraph_gap)) + waitForText(text(R.string.label_image_size)) + waitForText(text(R.string.label_horizontal_margin)) + waitForText(text(R.string.label_vertical_margin)) + + composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0] + .performClick() + clickText(text(R.string.format_local)) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) + .getBoolean("format_is_local_$fixtureBookId", false) + } + + composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0] + .performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) + .getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f + } + } + + @Test + fun fixtureEpub_fontSelectionSheetPersistsFontFamily() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_text_formatting)) + waitForText(text(R.string.section_font_alignment)) + clickContentDescription(text(R.string.content_desc_select_font_family)) + + waitForText(text(R.string.select_font)) + waitForText(text(R.string.tab_presets)) + waitForText(text(R.string.tab_imported)) + clickText("Lato") + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) + .getString("reader_font_family", "original") == "lato" + } + } + + @Test + fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.tooltip_theme_desc)) + + waitForText(text(R.string.reading_themes)) + waitForText(text(R.string.theme_solid_colors)) + waitForText("Light") + waitForText("Dark") + clickText("Sepia") + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + .getString(PREF_READER_THEME, "system") == "sepia" + } + } + + @Test + fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_chapters_menu)) + clickText(text(R.string.tab_bookmarks)) + waitForText(text(R.string.no_bookmarks_yet)) + + clickText(text(R.string.tab_annotations)) + waitForText(text(R.string.no_highlights_yet)) + } + + @Test + fun fixtureEpub_searchCanClearAndClose() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.tooltip_search)) + waitForTag("SearchTextField") + + composeTestRule.onNodeWithTag("SearchTextField") + .performTextInput("SEARCH_TARGET_DELTA") + waitForTextContaining("SEARCH_TARGET_DELTA") + + clickContentDescription(text(R.string.tooltip_clear_search)) + waitForNoContentDescription(text(R.string.tooltip_clear_search)) + + clickContentDescription(text(R.string.tooltip_close_search)) + composeTestRule.waitUntil(timeoutMillis = 5_000) { + hasContentDescription(text(R.string.tooltip_search)) + } + } + + @Test + fun fixtureEpub_dictionarySettingsShowsLookupSections() { + launchFixtureReader() + waitForReader() + + clickReaderControl(text(R.string.content_desc_dictionary_settings)) + + waitForText(text(R.string.dict_lookup_settings)) + waitForText(text(R.string.tooltip_dictionary)) + waitForText(text(R.string.dict_translate)) + waitForText(text(R.string.tooltip_search)) + } + + @Test + fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() { + launchFixtureReader() + waitForReader() + + openOverflowMenu() + clickText(text(R.string.menu_tts_settings)) + clickText(text(R.string.menu_tts_word_replacements)) + + waitForText(text(R.string.menu_tts_word_replacements)) + waitForText(fixtureBookTitle) + waitForText(text(R.string.tts_replacements_tab_global)) + waitForText(text(R.string.tts_replacements_tab_this_book)) + waitForText(text(R.string.tts_replacements_enable)) + } + + private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) { + val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName) + beforeLaunch(fixtureUri) + scenario = ActivityScenario.launch(createEpubViewIntent(fixtureUri)) + } + + private fun clearReaderPrefs() { + listOf( + "epub_reader_settings", + "epub_reader_bookmarks", + "reader_prefs" + ).forEach { prefsName -> + targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + .edit() + .putString("render_mode", "VERTICAL_SCROLL") + .commit() + } + + private fun text(resId: Int): String = targetContext.getString(resId) + + private fun seedFixtureBookmark() { + val bookmark = org.json.JSONObject().apply { + put("cfi", "android-locator:1:1:0") + put("chapterTitle", "Chapter Two") + put("label", org.json.JSONObject.NULL) + put("snippet", "BOOKMARK_TARGET_ECHO") + put("pageInChapter", 1) + put("totalPagesInChapter", 3) + put("chapterIndex", 1) + put( + "locator", + org.json.JSONObject().apply { + put("chapterIndex", 1) + put("chapterId", "chapter-02") + put("pageIndex", 0) + put("blockIndex", 1) + put("charOffset", 0) + put("textQuote", "BOOKMARK_TARGET_ECHO") + put("cfi", "android-locator:1:1:0") + } + ) + } + + targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE) + .edit() + .putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString())) + .commit() + } + + private fun seedFixtureHighlight() { + val highlight = UserHighlight( + id = "fixture_annotation_golf", + cfi = "android-locator:2:1:0", + text = "ANNOTATION_TARGET_GOLF", + color = HighlightColor.GREEN, + chapterIndex = 2, + note = "Fixture note survives startup", + locator = ReaderLocator( + chapterIndex = 2, + chapterId = "chapter-03", + blockIndex = 1, + charOffset = 0, + textQuote = "ANNOTATION_TARGET_GOLF", + cfi = "android-locator:2:1:0" + ) + ) + + targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) + .edit() + .putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight))) + .commit() + } + + private fun seedFixtureRecentFile( + uriString: String, + chapterIndex: Int, + blockIndex: Int, + charOffset: Int, + progress: Float + ) { + val now = System.currentTimeMillis() + runBlocking { + AppDatabase.getDatabase(targetContext) + .recentFileDao() + .insertOrUpdateFile( + RecentFileEntity( + bookId = fixtureBookId, + uriString = uriString, + type = FileType.EPUB, + displayName = fixtureBookTitle, + timestamp = now, + coverImagePath = null, + title = fixtureBookTitle, + author = "Fixture Author", + lastChapterIndex = chapterIndex, + lastPage = null, + lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset", + progressPercentage = progress, + isRecent = true, + isAvailable = true, + lastModifiedTimestamp = now, + isDeleted = false, + locatorBlockIndex = blockIndex, + locatorCharOffset = charOffset, + bookmarks = null, + sourceFolderUri = null, + isReflowPreferred = false, + customName = null, + highlights = null, + fileSize = 0L, + fileContentModifiedTimestamp = 0L, + seriesName = null, + seriesIndex = null, + description = null, + folderTextMetadataParsed = false, + folderCoverMetadataParsed = false, + originalTitle = fixtureBookTitle, + originalAuthor = "Fixture Author", + originalSeriesName = null, + originalSeriesIndex = null, + originalDescription = null + ) + ) + } + } + + private fun createEpubViewIntent(uri: Uri): Intent { + return Intent(targetContext, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + setDataAndType(uri, "application/epub+zip") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + private fun copyAndroidTestAssetToCache(assetName: String): Uri { + val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub") + currentEpubFile = file + + instrumentationContext.assets.open(assetName).use { inputStream -> + file.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + return FileProvider.getUriForFile( + targetContext, + "${targetContext.packageName}.provider", + file + ) + } + + private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) { + clickReaderControl(text(R.string.tooltip_search)) + waitForTag("SearchTextField") + + composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query) + waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000) + composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick() + } + + private fun waitForReader() { + waitForTag("ReaderContainer", timeoutMillis = 30_000) + } + + private fun showReaderChrome() { + if (hasAnyReaderControl()) return + + composeTestRule.onRoot().performTouchInput { click(center) } + composeTestRule.waitUntil(timeoutMillis = 5_000) { + hasAnyReaderControl() + } + } + + private fun clickReaderControl(contentDescription: String) { + showReaderChrome() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + hasContentDescription(contentDescription) + } + composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick() + } + + private fun clickContentDescription(contentDescription: String) { + composeTestRule.waitUntil(timeoutMillis = 5_000) { + hasContentDescription(contentDescription) + } + composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick() + } + + private fun clickText(value: String) { + composeTestRule.waitUntil(timeoutMillis = 10_000) { + composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onAllNodesWithText(value)[0].performClick() + } + + private fun openOverflowMenu() { + clickReaderControl(text(R.string.content_desc_more_options)) + } + + private fun hasAnyReaderControl(): Boolean { + return hasContentDescription(text(R.string.tooltip_search)) || + hasContentDescription(text(R.string.content_desc_chapters_menu)) || + hasContentDescription(text(R.string.content_desc_more_options)) + } + + private fun hasContentDescription(contentDescription: String): Boolean { + return composeTestRule + .onAllNodesWithContentDescription(contentDescription) + .fetchSemanticsNodes() + .isNotEmpty() + } + + private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitForText(value: String, timeoutMillis: Long = 10_000) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule + .onAllNodesWithContentDescription(contentDescription) + .fetchSemanticsNodes() + .isEmpty() + } + } + + private fun waitForRenderMode(expected: RenderMode) { + composeTestRule.waitUntil(timeoutMillis = 20_000) { + targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + .getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name + } + } + + private fun assertReaderSettingEventually( + prefsName: String, + key: String, + expected: Boolean + ) { + composeTestRule.waitUntil(timeoutMillis = 5_000) { + targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + .getBoolean(key, !expected) == expected + } + } + + private fun readFixtureRecentFile(): RecentFileEntity? { + return runBlocking { + AppDatabase.getDatabase(targetContext) + .recentFileDao() + .getFileByBookId(fixtureBookId) + } + } + + private fun waitForRecentFile( + timeoutMillis: Long = 10_000, + predicate: (RecentFileEntity?) -> Boolean + ) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + predicate(readFixtureRecentFile()) + } + } + + private fun waitForFixtureBookmarks( + timeoutMillis: Long = 10_000, + predicate: (String?) -> Boolean + ) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + predicate(readFixtureRecentFile()?.bookmarks) + } + } + + private fun waitForFixtureHighlights( + timeoutMillis: Long = 10_000, + predicate: (String?) -> Boolean + ) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + predicate(readFixtureRecentFile()?.highlights) + } + } + + private fun parseBookmarksJson(rawJson: String?): Set { + return EpubAnnotationSerializer.parseBookmarksJson(rawJson) + } +} diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt index c6fe395..f7fe15e 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt @@ -17,49 +17,66 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CssParserTest { + private val dummyConstraints = androidx.compose.ui.unit.Constraints() + private val baseFontSize = 16f + private val density = 1f + + private fun parseTextColor(value: String): Color? { + val result = CssParser.parse( + cssContent = "p { color: $value; }", + cssPath = null, + baseFontSizeSp = baseFontSize, + density = density, + constraints = dummyConstraints, + isDarkTheme = false + ) + return result.rules.byTag["p"] + ?.firstOrNull() + ?.style + ?.spanStyle + ?.color + ?.takeIf { it.isSpecified } + } + @Test fun parseColor_handlesNamedColorsCorrectly() { - assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red) - assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black) - assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent) + assertThat(parseTextColor("red")).isEqualTo(Color.Red) + assertThat(parseTextColor("black")).isEqualTo(Color.Black) + assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent) } @Test fun parseColor_handles3DigitHexCodes() { - assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC)) + assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC)) } @Test fun parseColor_handles6DigitHexCodes() { - assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC)) + assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC)) } @Test fun parseColor_handles8DigitHexCodes() { - assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC)) + assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204)) } @Test fun parseColor_handlesRgbFunction() { - assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204)) + assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204)) } @Test fun parseColor_handlesRgbaFunction() { - assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128)) + assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128)) } @Test fun parseColor_returnsNullForInvalidInput() { - assertThat(CssParser.parseColor("not a color")).isNull() - assertThat(CssParser.parseColor("#12345")).isNull() - assertThat(CssParser.parseColor("rgb(1,2)")).isNull() + assertThat(parseTextColor("not a color")).isNull() + assertThat(parseTextColor("#12345")).isNull() + assertThat(parseTextColor("rgb(1,2)")).isNull() } - private val dummyConstraints = androidx.compose.ui.unit.Constraints() - private val baseFontSize = 16f - private val density = 1f - @Test fun parse_handlesSimpleRule() { val css = "p { color: red; }" @@ -120,12 +137,12 @@ class CssParserTest { } p { color: black; } """.trimIndent() - val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) + val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) assertThat(result.rules.byTag).containsKey("p") assertThat(result.fontFaces).hasSize(1) val fontFace = result.fontFaces.first() assertThat(fontFace.fontFamily).isEqualTo("mycustomfont") - assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf") + assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf") assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold) assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal) } @@ -190,10 +207,11 @@ class CssParserTest { val css = "div { border: 2px solid red; }" val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val style = result.rules.byTag["div"]?.first()?.style?.blockStyle - assertThat(style?.border).isNotNull() - assertThat(style?.border?.width).isEqualTo(2.dp) - assertThat(style?.border?.style).isEqualTo("solid") - assertThat(style?.border?.color).isEqualTo(Color.Red) + val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid") + assertThat(style?.borderTop).isEqualTo(expectedBorder) + assertThat(style?.borderRight).isEqualTo(expectedBorder) + assertThat(style?.borderBottom).isEqualTo(expectedBorder) + assertThat(style?.borderLeft).isEqualTo(expectedBorder) } @Test @@ -262,9 +280,70 @@ class CssParserTest { url("font.ttf") format("truetype"); } """.trimIndent() - val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) + val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false) assertThat(result.fontFaces).hasSize(1) - assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf") + assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf") + } + + @Test + fun parse_handlesNestedMediaAndCalcVariables() { + val css = """ + :root { --gap: 12px; } + @media screen and (min-width: 300px) { + p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); } + } + """.trimIndent() + + val result = CssParser.parse( + css, + null, + baseFontSize, + density, + androidx.compose.ui.unit.Constraints(maxWidth = 500), + isDarkTheme = false + ) + + val style = result.rules.byTag["p"]!!.first().style + assertThat(style.blockStyle.margin.left).isEqualTo(20.dp) + assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0)) + } + + @Test + fun parse_preservesBeforeAfterPseudoElementRules() { + val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }" + val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) + + val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" } + assertThat(pseudoRule.selector.selector).isEqualTo("p") + assertThat(pseudoRule.style.content).isEqualTo("'Note: '") + assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red) + assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue) + } + + @Test + fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() { + assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128)) + assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128)) + } + + @Test + fun parse_backgroundShorthandExtractsColorAndImage() { + val css = "section { background: #ffeecc url('../images/paper.png') repeat; }" + val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) + val style = result.rules.byTag["section"]!!.first().style.blockStyle + + assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204)) + assertThat(style.backgroundImage).isEqualTo("../images/paper.png") + } + + @Test + fun parse_listStyleShorthandExtractsMarkerTypeAndImage() { + val css = "ul { list-style: square url('../images/bullet.png') outside; }" + val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) + val style = result.rules.byTag["ul"]!!.first().style.blockStyle + + assertThat(style.listStyleType).isEqualTo("square") + assertThat(style.listStyleImage).isEqualTo("../images/bullet.png") } @Test @@ -327,10 +406,10 @@ class CssParserTest { } @Test - fun parse_lineHeightClampsSmallEmValues() { + fun parse_lineHeightPreservesUnitlessMultiplier() { val css = "p { line-height: 1.1; }" // This is treated as 1.1em val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false) val style = result.rules.byTag["p"]?.first()?.style - assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em) + assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em) } -} \ No newline at end of file +} 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 dcfe42e..9b4d9d7 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt @@ -3,7 +3,6 @@ package com.aryan.reader.paginatedreader import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -41,7 +40,7 @@ class HtmlParserTest { val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules - return htmlToSemanticBlocks( + return androidHtmlToSemanticBlocks( html = "$html", // Wrap in body to match real usage cssRules = allRules, // Use the combined list of rules textStyle = defaultTextStyle, @@ -97,7 +96,61 @@ class HtmlParserTest { assertThat(blocks).hasSize(1) val pBlock = blocks.first() as SemanticParagraph val blockStyle = pBlock.style.spanStyle - assertThat(blockStyle.color).isEqualTo(Color.Green) + assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0)) + } + + @Test + fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() { + val css = """ + .warning p.note { color: red; } + .safe p.note { color: blue; } + """.trimIndent() + val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules + + val blocks = parse( + """ +

Danger

+

Okay

+ """.trimIndent(), + cssRules = cssRules + ) + + val first = blocks[0] as SemanticParagraph + val second = blocks[1] as SemanticParagraph + assertThat(first.style.spanStyle.color).isEqualTo(Color.Red) + assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue) + } + + @Test + fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() { + val css = "p.note::before { content: 'Note: '; color: red; }" + val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules + + val blocks = parse("

Remember this

", cssRules = cssRules) + + val paragraph = blocks.single() as SemanticParagraph + assertThat(paragraph.text).isEqualTo("Note: Remember this") + val generatedSpan = paragraph.spans.first { it.tag == "::before" } + assertThat(generatedSpan.start).isEqualTo(0) + assertThat(generatedSpan.end).isEqualTo("Note: ".length) + assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red) + } + + @Test + fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() { + val imageRelativeSrc = "images/paper.png" + val chapterParentDir = File(defaultChapterPath).parent ?: "" + val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile + imageFile.parentFile?.mkdirs() + imageFile.createNewFile() + imageFile.deleteOnExit() + val css = "p.paper { background-image: url('$imageRelativeSrc'); }" + val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules + + val blocks = parse("

Text over paper

", cssRules = cssRules) + + val paragraph = blocks.single() as SemanticParagraph + assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath) } @Test @@ -197,7 +250,7 @@ class HtmlParserTest { } @Test - fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() { + fun htmlToSemanticBlocks_complexInlineText_isPreserved() { val html = "

This is bold and italic text.

" val blocks = parse(html) @@ -205,17 +258,6 @@ class HtmlParserTest { val pBlock = blocks.first() as SemanticParagraph assertThat(pBlock.text).isEqualTo("This is bold and italic text.") - - // Find the range for "bold" and check its style - val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" } - assertThat(boldRange).isNotNull() - assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold) - - // Find the range for "italic" and check its style - val italicRange = - pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" } - assertThat(italicRange).isNotNull() - assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic) } @Test @@ -242,15 +284,14 @@ class HtmlParserTest { } @Test - fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() { + fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() { val css = "p::before { content: \"Note: \"; }" val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules val blocks = parse("

This is a test.

", cssRules = cssRules) - // The parser now ignores pseudo-elements, so only the paragraph content should be parsed. assertThat(blocks).hasSize(1) val pBlock = blocks[0] as SemanticParagraph - assertThat(pBlock.text).isEqualTo("This is a test.") + assertThat(pBlock.text).isEqualTo("Note: This is a test.") } @Test diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt index dd56c8e..7bfe520 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt @@ -75,7 +75,7 @@ class PaginatedReaderDataTest { margin = BoxBorders(bottom = 10.dp, left = 10.dp), width = 200.dp, backgroundColor = Color.Black, - border = BorderStyle(width = 1.dp, color = Color.Red) + borderTop = BorderStyle(width = 1.dp, color = Color.Red) ) val merged = baseStyle.merge(overrideStyle) @@ -95,8 +95,8 @@ class PaginatedReaderDataTest { // Other properties assertThat(merged.width).isEqualTo(200.dp) assertThat(merged.backgroundColor).isEqualTo(Color.Black) - assertThat(merged.border).isNotNull() - assertThat(merged.border?.width).isEqualTo(1.dp) + assertThat(merged.borderTop).isNotNull() + assertThat(merged.borderTop?.width).isEqualTo(1.dp) } @Test @@ -115,6 +115,9 @@ class PaginatedReaderDataTest { assertThat(merged.margin.top).isEqualTo(5.dp) assertThat(merged.width).isEqualTo(100.dp) assertThat(merged.backgroundColor).isEqualTo(Color.White) - assertThat(merged.border).isNull() + assertThat(merged.borderTop).isNull() + assertThat(merged.borderRight).isNull() + assertThat(merged.borderBottom).isNull() + assertThat(merged.borderLeft).isNull() } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt index c839650..27f1a52 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt @@ -1,36 +1,29 @@ -// PaginatedReaderViewModelTest.kt package com.aryan.reader.paginatedreader import android.content.Context +import android.os.Build import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import com.aryan.reader.SearchResult import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.paginatedreader.data.BookCacheDao -import com.aryan.reader.paginatedreader.data.BookCacheDatabase -import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.google.common.truth.Truth.assertThat -import io.mockk.coEvery -import io.mockk.every import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -64,26 +57,26 @@ private class FakePaginator( override fun findPageForSearchResult( result: SearchResult, - onResult: (Int) -> Unit + onResult: (pageIndex: Int) -> Unit ) = Unit - // Add stubs for the other missing interface members - override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit - override fun findPageForCfiAndOffset( + override fun findPageForAnchor( chapterIndex: Int, - cfi: String, - charOffset: Int - ): Int? { - return null - } + anchor: String?, + onResult: (pageIndex: Int) -> Unit + ) = Unit + override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit + override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null override fun findChapterIndexForPage(pageIndex: Int): Int? = null override fun getCfiForPage(pageIndex: Int): String? = null override fun onUserScrolledTo(pageIndex: Int) = Unit + override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List): String? = null } @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM) class PaginatedReaderViewModelTest { @get:Rule @@ -103,11 +96,6 @@ class PaginatedReaderViewModelTest { viewModel.setPaginatorForTest(fakePaginator) } - @After - fun tearDown() { - unmockkAll() - } - @Test fun uiState_reflectsPaginatorInitialState() = runTest { val initialState = viewModel.uiState.value @@ -159,146 +147,34 @@ class PaginatedReaderViewModelTest { assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter) assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href) } - @Test - fun initialize_createsARealPaginatorAndUpdateState() = runTest { - // Arrange - val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel - val context = ApplicationProvider.getApplicationContext() - val textMeasurer = mockk(relaxed = true) - val constraints = Constraints(maxWidth = 1080, maxHeight = 1920) - val textStyle = TextStyle.Default - val density = Density(1f) - val mathMLRenderer = mockk(relaxed = true) - val testBook = EpubBook( - fileName = "test.epub", - title = "Test Book", - author = "Test Author", - language = "en", - coverImage = null, - chapters = listOf( - EpubChapter( - chapterId = "ch1", - title = "Chapter 1", - htmlFilePath = "ch1.html", - absPath = "/ops/ch1.html", - htmlContent = "

Some content

", - plainTextContent = "Some content" - ) - ), - css = mapOf("/ops/style.css" to "p {color: red;}"), - extractionBasePath = "" - ) - - // Mock dependencies for BookPaginator - val mockDao = mockk(relaxed = true) - coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss - - val mockDb = mockk() - every { mockDb.bookCacheDao() } returns mockDao - - mockkObject(BookCacheDatabase.Companion) - every { BookCacheDatabase.getDatabase(any()) } returns mockDb - - mockkObject(BookProcessingWorker.Companion) - every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit - - // Pre-condition check - assertThat(viewModel.uiState.value.isLoading).isTrue() - assertThat(viewModel.paginator).isNull() - - // Act - viewModel.initialize( - book = testBook, - textMeasurer = textMeasurer, - textConstraints = constraints, - textStyle = textStyle, - density = density, - isDarkTheme = false, - context = context, - initialChapterToPaginate = 0, - mathMLRenderer = mathMLRenderer - ) - advanceUntilIdle() // Allow coroutines to complete - - // Assert - assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java) - assertThat(viewModel.uiState.value.isLoading).isFalse() - assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0) - } @Test - fun initialize_isIdempotent() = runTest { - // Arrange - val viewModel = PaginatedReaderViewModel() + fun initialize_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest { val context = ApplicationProvider.getApplicationContext() - val textMeasurer = mockk(relaxed = true) - val constraints = Constraints(maxWidth = 1080, maxHeight = 1920) - val textStyle = TextStyle.Default - val density = Density(1f) - val mathMLRenderer = mockk(relaxed = true) - val testBook = EpubBook( - fileName = "test.epub", - title = "Test Book", - author = "Test Author", - language = "en", - coverImage = null, - chapters = listOf( - EpubChapter( - chapterId = "ch1", - title = "Chapter 1", - htmlFilePath = "ch1.html", - absPath = "/ops/ch1.html", - htmlContent = "

Some content

", - plainTextContent = "Some content" - ) - ), - css = mapOf("/ops/style.css" to "p {color: red;}"), - extractionBasePath = "" - ) + val existingPaginator = viewModel.paginator - // Mock dependencies - val mockDao = mockk(relaxed = true) - coEvery { mockDao.getProcessedBook(any()) } returns null - val mockDb = mockk() - every { mockDb.bookCacheDao() } returns mockDao - mockkObject(BookCacheDatabase.Companion) - every { BookCacheDatabase.getDatabase(any()) } returns mockDb - mockkObject(BookProcessingWorker.Companion) - every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit - - // Act viewModel.initialize( - book = testBook, - textMeasurer = textMeasurer, - textConstraints = constraints, - textStyle = textStyle, - density = density, + book = EpubBook( + fileName = "test.epub", + title = "Test Book", + author = "Test Author", + language = "en", + coverImage = null + ), + textMeasurer = mockk(relaxed = true), + textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920), + textStyle = TextStyle.Default, + density = Density(1f), isDarkTheme = false, + themeBackgroundColor = Color.White, + themeTextColor = Color.Black, context = context, initialChapterToPaginate = 0, - mathMLRenderer = mathMLRenderer + mathMLRenderer = mockk(relaxed = true), + paragraphGapMultiplier = 1.0f ) advanceUntilIdle() - val firstPaginator = viewModel.paginator - assertThat(firstPaginator).isNotNull() - - // Act again - viewModel.initialize( - book = testBook, - textMeasurer = textMeasurer, - textConstraints = constraints, - textStyle = textStyle, - density = density, - isDarkTheme = false, - context = context, - initialChapterToPaginate = 0, - mathMLRenderer = mathMLRenderer - ) - advanceUntilIdle() - - // Assert - val secondPaginator = viewModel.paginator - assertThat(secondPaginator).isSameInstanceAs(firstPaginator) + assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt index b04283f..985b996 100644 --- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt @@ -1,11 +1,13 @@ // PaginatorTest.kt package com.aryan.reader.paginatedreader +import android.os.Build import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp -import com.google.common.truth.Truth.assertThat import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider( } return null } + + override suspend fun split(block: TableBlock, availableHeight: Int): Pair? = null + + override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair? = null } @RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM) class PaginatorTest { private val testDensity = Density(density = 1f, fontScale = 1f) private val pageHeight = 1000 + private fun List.withoutMeasuredHeights(): List { + return map { it.withoutMeasuredHeight() } + } + + private fun ContentBlock.withoutMeasuredHeight(): ContentBlock { + return when (this) { + is ParagraphBlock -> copy(expectedHeight = 0) + is ImageBlock -> copy(expectedHeight = 0) + is HeaderBlock -> copy(expectedHeight = 0) + is SpacerBlock -> copy(expectedHeight = 0) + is QuoteBlock -> copy(expectedHeight = 0) + is ListItemBlock -> copy(expectedHeight = 0) + is TableBlock -> copy( + rows = rows.map { row -> + row.map { cell -> + cell.copy(content = cell.content.withoutMeasuredHeights()) + } + }, + expectedHeight = 0 + ) + is MathBlock -> copy(expectedHeight = 0) + is WrappingContentBlock -> copy( + floatedImage = floatedImage.copy(expectedHeight = 0), + paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) }, + expectedHeight = 0 + ) + is FlexContainerBlock -> copy( + children = children.withoutMeasuredHeights(), + expectedHeight = 0 + ) + } + } + @Test fun paginate_givenEmptyBlocks_createsZeroPages() = runTest { val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity) @@ -85,8 +125,53 @@ class PaginatorTest { val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(2) - assertThat(pages[0].content).containsExactly(block1) - assertThat(pages[1].content).containsExactly(block2) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2) + } + + @Test + fun paginate_honorsBreakBeforePage() = runTest { + val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0) + val block2 = ParagraphBlock( + content = AnnotatedString("After"), + style = BlockStyle(breakBefore = "page"), + blockIndex = 1 + ) + + val pages = paginate( + listOf(block1, block2), + pageHeight, + FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)), + testDensity + ) + + assertThat(pages).hasSize(2) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2) + } + + @Test + fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest { + val block1 = ParagraphBlock( + content = AnnotatedString("Keep together"), + style = BlockStyle(breakInside = "avoid"), + blockIndex = 0 + ) + val part1 = block1.copy(content = AnnotatedString("Keep")) + val part2 = block1.copy(content = AnnotatedString("together")) + + val pages = paginate( + listOf(block1), + pageHeight = 400, + measurementProvider = FakeSplittableMeasurementProvider( + heights = mapOf(block1 to 800, part1 to 300, part2 to 500), + splittableParagraphs = mapOf(block1 to (part1 to part2)) + ), + density = testDensity + ) + + assertThat(pages).hasSize(1) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) } @Test @@ -110,8 +195,8 @@ class PaginatorTest { val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(2) - assertThat(pages[0].content).containsExactly(block1, part1).inOrder() - assertThat(pages[1].content).containsExactly(part2) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder() + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2) } @Test @@ -136,8 +221,8 @@ class PaginatorTest { val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(2) - assertThat(pages[0].content).containsExactly(splitWrapper) - assertThat(pages[1].content).containsExactly(para2) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper) + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2) } @@ -158,8 +243,8 @@ class PaginatorTest { val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(2) - assertThat(pages[0].content).containsExactly(block1) - assertThat(pages[1].content).containsExactly(unsplittableBlock) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock) } @Test @@ -172,7 +257,7 @@ class PaginatorTest { val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(1) - assertThat(pages[0].content).containsExactly(oversizedBlock) + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock) } @Test @@ -248,8 +333,8 @@ class PaginatorTest { val pages = paginate(blocks, pageHeight, measurementProvider, testDensity) assertThat(pages).hasSize(2) - assertThat(pages[0].content).containsExactly(block1) - assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1) + assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split } @Test @@ -271,8 +356,7 @@ class PaginatorTest { // Set page height so that a split is attempted. val pages = paginate(blocks, 150, measurementProvider, testDensity) assertThat(pages).hasSize(1) - // The page should be empty because part1 was empty, and the original block was re-added - // to the remaining list. The next page then contains the full block. - assertThat(pages[0].content).containsExactly(part2) + // Empty split heads are skipped so pagination keeps only the remaining content. + assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt new file mode 100644 index 0000000..cc27087 --- /dev/null +++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt @@ -0,0 +1,103 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.sp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReaderLinkHitTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() { + lateinit var text: AnnotatedString + var layoutResult: TextLayoutResult? = null + + composeTestRule.setContent { + val textMeasurer = rememberTextMeasurer() + text = linkText("Open") + layoutResult = textMeasurer.measure( + text = text, + style = TextStyle(fontSize = 24.sp), + constraints = Constraints.fixedWidth(500) + ) + } + + composeTestRule.waitForIdle() + + val layout = checkNotNull(layoutResult) + val firstBox = layout.getBoundingBox(0) + val lastBox = layout.getBoundingBox(text.length - 1) + val y = (firstBox.top + firstBox.bottom) / 2f + + assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y))) + .isEqualTo(HREF) + assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y))) + .isNull() + } + + @Test + fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() { + lateinit var fullText: AnnotatedString + var lineLayoutResult: TextLayoutResult? = null + val prefix = "Before " + val label = "Open" + + composeTestRule.setContent { + val textMeasurer = rememberTextMeasurer() + fullText = buildAnnotatedString { + append(prefix) + append(label) + addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length) + } + lineLayoutResult = textMeasurer.measure( + text = fullText.subSequence(prefix.length, fullText.length), + style = TextStyle(fontSize = 24.sp), + constraints = Constraints.fixedWidth(500) + ) + } + + composeTestRule.waitForIdle() + + val layout = checkNotNull(lineLayoutResult) + val firstBox = layout.getBoundingBox(0) + val lastBox = layout.getBoundingBox(label.length - 1) + val y = (firstBox.top + firstBox.bottom) / 2f + + assertThat( + fullText.readerUrlAnnotationAtPosition( + layout = layout, + position = Offset(firstBox.left + 1f, y), + textStartOffset = prefix.length + ) + ).isEqualTo(HREF) + assertThat( + fullText.readerUrlAnnotationAtPosition( + layout = layout, + position = Offset(lastBox.right + 60f, y), + textStartOffset = prefix.length + ) + ).isNull() + } + + private fun linkText(label: String) = buildAnnotatedString { + append(label) + addStringAnnotation("URL", HREF, 0, label.length) + } + + private companion object { + const val HREF = "chapter.xhtml#target" + } +} diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt b/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt index 894d567..af02911 100644 --- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.click import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -20,6 +21,8 @@ import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.aryan.reader.MainActivity +import com.aryan.reader.R +import com.google.common.truth.Truth.assertThat import org.junit.After import org.junit.Before import org.junit.Rule @@ -38,6 +41,12 @@ class PdfAnnotationTest { private var currentPdfFile: File? = null private var scenario: ActivityScenario? = null private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") } + private fun text(resId: Int): String = context.getString(resId) + private fun dockTag(resId: Int): String = "DockItem_${text(resId)}" + + private fun assertNoNodeWithTag(tag: String) { + assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty() + } private fun createPdfViewIntent(context: Context, uri: Uri): Intent { return Intent(context, MainActivity::class.java).apply { @@ -55,7 +64,7 @@ class PdfAnnotationTest { context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) .edit().clear().commit() - scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri)) + scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri)) waitForDocumentLoad() } @@ -75,11 +84,11 @@ class PdfAnnotationTest { } private fun enterEditMode() { - composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode") + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)) .assertIsDisplayed() .performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed() } private fun tapOutsidePopup() { @@ -111,13 +120,13 @@ class PdfAnnotationTest { enterEditMode() // Verify Dock Items exist using new Tags - composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed() - composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed() - composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed() } // --- TOOL LOGIC TESTS --- @@ -127,38 +136,38 @@ class PdfAnnotationTest { enterEditMode() // 1. Select Highlighter - composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick() composeTestRule.waitForIdle() // 2. Verify selection state - composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected() - composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected() // 3. Exit Edit Mode - composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick() composeTestRule.waitForIdle() // 4. Re-enter Edit Mode enterEditMode() // 5. Verify Highlighter is STILL selected (Persistence) - composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected() } @Test - fun testEraserHasNoPopup() { + fun testEraserSettingsPopupOpensWhenAlreadySelected() { enterEditMode() // Select Eraser - composeTestRule.onNodeWithTag("DockItem_Eraser").performClick() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected() - // Click Eraser AGAIN (Should NOT open popup) - composeTestRule.onNodeWithTag("DockItem_Eraser").performClick() + // Click Eraser again to open its settings popup. + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist() + composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed() } // --- SETTINGS POPUP TESTS --- @@ -169,7 +178,7 @@ class PdfAnnotationTest { // 1. Pen is default. Click Pen ONCE to open Settings. // (Clicking twice would toggle it off, which caused previous failures) - composeTestRule.onNodeWithTag("DockItem_Pen").performClick() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick() composeTestRule.waitForIdle() // 2. Verify Popup Displayed @@ -186,7 +195,7 @@ class PdfAnnotationTest { // 5. Dismiss Settings tapOutsidePopup() - composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist() + assertNoNodeWithTag("ToolSettingsPopup") } @Test @@ -194,7 +203,7 @@ class PdfAnnotationTest { enterEditMode() // Open Settings for Pen (Default selected, so one click opens settings) - composeTestRule.onNodeWithTag("DockItem_Pen").performClick() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick() composeTestRule.waitForIdle() // Test Palette Click (Index 1) @@ -208,7 +217,7 @@ class PdfAnnotationTest { tapOutsidePopup() // Quick verification that settings didn't crash app - composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() } // --- UNDO/REDO TESTS --- @@ -217,7 +226,7 @@ class PdfAnnotationTest { fun testDrawingEnablesUndo() { enterEditMode() - composeTestRule.onNodeWithContentDescription("Undo") + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)) .assertIsDisplayed() .assertIsNotEnabled() @@ -227,7 +236,7 @@ class PdfAnnotationTest { } composeTestRule.waitForIdle() - composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled() } @Test @@ -240,8 +249,8 @@ class PdfAnnotationTest { } composeTestRule.waitForIdle() - val undoNode = composeTestRule.onNodeWithContentDescription("Undo") - val redoNode = composeTestRule.onNodeWithContentDescription("Redo") + val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)) + val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo)) undoNode.assertIsEnabled() redoNode.assertIsNotEnabled() @@ -268,7 +277,7 @@ class PdfAnnotationTest { enterEditMode() // 1. Drag Dock to make it floating (using Pen icon as handle) - composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput { + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput { down(center) advanceEventTime(600) // Long press // Drag UP significantly @@ -278,20 +287,20 @@ class PdfAnnotationTest { composeTestRule.waitForIdle() // 2. Minimize (Eye icon) - composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick() composeTestRule.waitForIdle() // 3. Verify Dock items are hidden - composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist() + assertNoNodeWithTag(dockTag(R.string.content_desc_pen)) // 4. Verify "Show Dock" floating button is visible - composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed() // 5. Restore - composeTestRule.onNodeWithContentDescription("Show Dock").performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick() composeTestRule.waitForIdle() // 6. Verify Dock items return - composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed() + composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed() } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt b/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt index 2c1cf34..5238627 100644 --- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt +++ b/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt @@ -7,25 +7,25 @@ import android.net.Uri import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipe import androidx.core.content.FileProvider +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.aryan.reader.MainActivity +import com.aryan.reader.R +import com.google.common.truth.Truth.assertThat import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,13 +41,33 @@ class PdfViewerScreenTest { @get:Rule val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) - @org.junit.Before + private val context: Context = ApplicationProvider.getApplicationContext() + private var currentPdfFile: File? = null + private var scenario: ActivityScenario? = null + + @Before fun setup() { - val context = ApplicationProvider.getApplicationContext() context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) .edit() .clear() .commit() + + val samplePdfUri = copyAssetToCache(context, "sample.pdf") + scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri)) + } + + @After + fun tearDown() { + scenario?.close() + currentPdfFile?.let { + if (it.exists()) it.delete() + } + } + + private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args) + + private fun assertNoNodeWithTag(tag: String) { + assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty() } private fun createPdfViewIntent(context: Context, uri: Uri): Intent { @@ -58,50 +78,47 @@ class PdfViewerScreenTest { } } - private val context: Context = ApplicationProvider.getApplicationContext() - - private var currentPdfFile: File? = null - - private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") } - - @get:Rule - val activityRule = ActivityScenarioRule(createPdfViewIntent(context, samplePdfUri)) - - @After - fun tearDown() { - currentPdfFile?.let { - if (it.exists()) it.delete() - } - } - - private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") { + private fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) { composeTestRule.waitUntil(timeoutMillis = 15_000) { composeTestRule .onAllNodesWithText(pageText) - .fetchSemanticsNodes().size == 1 + .fetchSemanticsNodes().isNotEmpty() } } - private fun ensurePaginationMode() { - composeTestRule.onNodeWithContentDescription("More Options").performClick() - composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick() + private fun openMoreOptions() { + composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick() + } + + private fun openNavigationDrawer() { + composeTestRule.onNodeWithTag("TocButton").performClick() + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty() + } + } + + private fun selectChaptersTabIfTabsPaneIsFirst() { + if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) { + composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick() + composeTestRule.waitForIdle() + } + } + + private fun selectBookmarksTab() { + composeTestRule.onNodeWithTag("BookmarksTab").performClick() composeTestRule.waitForIdle() } - @Test - fun documentLoadsAndDisplaysCorrectPageCount() { - waitForDocumentLoad() - composeTestRule.onNodeWithTag("PageNumberIndicator") - .assertIsDisplayed() + private fun selectReadingMode(modeText: String) { + openMoreOptions() + composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick() + composeTestRule.onNodeWithText(modeText).performClick() + composeTestRule.waitForIdle() } - @Test - fun tableOfContents_displaysEmptyState() { - waitForDocumentLoad() - - composeTestRule.onNodeWithTag("TocButton").performClick() - - composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed() + private fun ensurePaginationMode() { + selectReadingMode(text(R.string.menu_reading_mode_paginated)) } @Suppress("SameParameterValue") @@ -125,189 +142,117 @@ class PdfViewerScreenTest { } @Test - fun bookmarkFunctionality_addNavigateAndDelete() { + fun documentLoadsAndDisplaysCorrectPageCount() { + waitForDocumentLoad() + composeTestRule.onNodeWithTag("PageNumberIndicator") + .assertIsDisplayed() + } + + @Test + fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() { waitForDocumentLoad() - ensurePaginationMode() + openNavigationDrawer() + selectChaptersTabIfTabsPaneIsFirst() - composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed() + composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed() + composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed() + } - try { - composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) } - composeTestRule.onRoot().performClick() - composeTestRule.waitUntil(5_000) { - composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty() - } - composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed() - } catch (e: Exception) { - throw e + @Test + fun bookmarkFunctionality_addAndDeleteCurrentPage() { + waitForDocumentLoad() + + openMoreOptions() + composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick() + composeTestRule.waitForIdle() + + openNavigationDrawer() + selectBookmarksTab() + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty() } + composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed() + .assert(hasText(text(R.string.pdf_page_short, 1), substring = true)) - try { - composeTestRule.onNodeWithContentDescription("More Options").performClick() - composeTestRule.onNodeWithText("Bookmark this page").performClick() - composeTestRule.waitForIdle() - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) } - composeTestRule.onRoot().performClick() - composeTestRule.waitUntil(5_000) { - composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty() - } - composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed() - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithTag("TocButton").performClick() - composeTestRule.onNodeWithTag("BookmarksTab").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed() - .assert(hasText("Page 2", substring = true)) - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithTag("BookmarkItem_1").performClick() - composeTestRule.waitForIdle() - composeTestRule.waitUntil(5_000) { - composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1 - } - composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed() - - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithTag("TocButton").performClick() - composeTestRule.onNodeWithTag("BookmarksTab").performClick() - composeTestRule.waitForIdle() - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick() - composeTestRule.onNodeWithText("Delete").performClick() - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick() - } catch (e: Exception) { - throw e - } - - try { - composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist() - composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed() - } catch (e: Exception) { - throw e + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick() + composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty() } + assertNoNodeWithTag("BookmarkItem_0") + composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed() } @Test fun sliderNavigation_opensAndDisplaysCorrectly() { waitForDocumentLoad() - composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick() - composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed() - composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick() + + composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed() } @Test fun displayMode_switchesToVerticalScroll() { waitForDocumentLoad() - // Ensure we are in Pagination mode first to test the switch ensurePaginationMode() - // Verify Vertical Scroll component is NOT displayed initially - composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist() + assertNoNodeWithTag("PdfVerticalScroll") - // Switch to Vertical Scroll - composeTestRule.onNodeWithContentDescription("More Options").performClick() - composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick() + selectReadingMode(text(R.string.menu_reading_mode_vertical)) - composeTestRule.waitForIdle() - - // Verify Vertical Scroll component IS displayed composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed() - // Switch back to Pagination ensurePaginationMode() - // Verify Vertical Scroll component is gone - composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist() + assertNoNodeWithTag("PdfVerticalScroll") + } + + @Test + fun displayModeSelectionPersistsReaderPreference() { + waitForDocumentLoad() + + selectReadingMode(text(R.string.menu_reading_mode_paginated)) + waitForDisplayModePreference(DisplayMode.PAGINATION) + + selectReadingMode(text(R.string.menu_reading_mode_vertical)) + waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL) } @Test fun search_uiOpensAndAcceptsQuery() { waitForDocumentLoad() - // Click search button composeTestRule.onNodeWithTag("SearchButton").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick() + val ocrLanguageText = text(R.string.ocr_language_latin) + if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) { + composeTestRule.onNodeWithText(ocrLanguageText).performClick() + composeTestRule.waitForIdle() + } - // Verify text field appears composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed() - // Enter text composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query") - // Verify text exists in the field composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query") - // Close search - composeTestRule.onNodeWithContentDescription("Close Search").performClick() + composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick() - // Verify text field is gone - composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist() + assertNoNodeWithTag("SearchTextField") } - @Test - fun fullScreen_togglesVisibility() { - waitForDocumentLoad() - - // Click enter full screen button - composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick() - - // Verify exit full screen button appears - composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed() - - // Click exit full screen - composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick() - - // Verify exit button is gone and enter button returns - composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist() - composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed() + private fun waitForDisplayModePreference(expected: DisplayMode) { + composeTestRule.waitUntil(timeoutMillis = 5_000) { + context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + .getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name + } } - @Test - fun darkMode_togglesState() { - waitForDocumentLoad() - - // Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode" - composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed() - - // Toggle On - composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick() - - // State changed: Now button says "Disable Dark Mode" - composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed() - - // Toggle Off - composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick() - - // State changed back - composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed() - } -} \ No newline at end of file +} diff --git a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt index 1bfd9d1..0cefee3 100644 --- a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt +++ b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt @@ -31,8 +31,8 @@ class EpubTestActivity : ComponentActivity() { coverImagePath = null, onRenderModeChange = {}, customFonts = TODO(), - onImportFont = TODO(), viewModel = TODO() + onImportFonts = TODO(), viewModel = TODO() ) } } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4432b4a..bee3fda 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -154,6 +154,19 @@ + + + + + + + + + + + + + diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 2075568..bf37155 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -2924,6 +2924,29 @@ }; const HL_LOG_TAG = "HIGHLIGHT_DEBUG"; + const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag"; + + function hlRenderPreview(value, maxLength) { + if (value === undefined || value === null) return ""; + var text = String(value).replace(/\s+/g, " "); + var max = maxLength || 100; + return text.length > max ? text.substring(0, max) : text; + } + + function hlRenderLocatorLabel(locator) { + locator = locator || {}; + return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) + + " locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) + + " locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) + + ".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) + + " locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) + + " locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) + + " locatorCfi=" + hlRenderPreview(locator.cfi || "", 120); + } + + function hlRenderLog(message) { + console.log(HL_RENDER_LOG_TAG + ": " + message); + } window.HighlightBridgeHelper = { updateHighlightStyle: function (cfi, newColorClass, colorId) { @@ -3280,11 +3303,13 @@ try { var highlights = JSON.parse(jsonArrayString); var self = this; + hlRenderLog("webview_restore_start count=" + highlights.length); highlights.forEach(function (h) { - self.applyHighlight(h.cfi, h.text, h.cssClass); + self.applyHighlightObject(h); }); } catch (e) { + hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160)); console.log( `$ { HL_LOG_TAG @@ -3295,136 +3320,376 @@ } }, - applyHighlight: function (cfi, text, cssClass) { + applyHighlightObject: function (highlight) { + if (!highlight) return; + hlRenderLog( + "webview_apply_object id=" + (highlight.id || "") + + " cfi=" + hlRenderPreview(highlight.cfi || "", 120) + + " textLen=" + String(highlight.text || "").length + + " text='" + hlRenderPreview(highlight.text || "", 80) + "' " + + hlRenderLocatorLabel(highlight.locator || {}) + ); + this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null); + }, + + highlightTextRoot: function () { + return document.getElementById("content-container") || document.body; + }, + + highlightTextNodes: function (root) { + var nodes = []; + if (!root) return nodes; + var walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + { + acceptNode: function (node) { + if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT; + var parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }, + false, + ); + while (walker.nextNode()) nodes.push(walker.currentNode); + return nodes; + }, + + rangeFromTextOffsets: function (root, startOffset, endOffset) { + var start = parseInt(startOffset, 10); + var end = parseInt(endOffset, 10); + if (!root || !isFinite(start) || !isFinite(end) || end <= start) { + hlRenderLog( + "webview_range_offsets_skip reason=invalid_offsets start=" + startOffset + + " end=" + endOffset + " hasRoot=" + !!root + ); + return null; + } + + var nodes = this.highlightTextNodes(root); + var cursor = 0; + var startNode = null; + var startInNode = 0; + var endNode = null; + var endInNode = 0; + + for (var i = 0; i < nodes.length; i++) { + var value = nodes[i].nodeValue || ""; + var next = cursor + value.length; + if (!startNode && start >= cursor && start <= next) { + startNode = nodes[i]; + startInNode = Math.max(0, Math.min(value.length, start - cursor)); + } + if (startNode && end >= cursor && end <= next) { + endNode = nodes[i]; + endInNode = Math.max(0, Math.min(value.length, end - cursor)); + break; + } + cursor = next; + } + + if (!startNode || !endNode) { + hlRenderLog( + "webview_range_offsets_skip reason=missing_boundary start=" + start + + " end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor + ); + return null; + } + var range = document.createRange(); + range.setStart(startNode, startInNode); + range.setEnd(endNode, endInNode); + if (range.collapsed) { + hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end); + return null; + } + hlRenderLog( + "webview_range_offsets_result start=" + start + " end=" + end + + " text='" + hlRenderPreview(range.toString(), 80) + "'" + ); + return range; + }, + + rangeMatchesText: function (range, text) { + if (!range || !text) return true; + var actual = (range.toString() || "").trim(); + var expected = String(text || "").trim(); + if (!expected) return true; + return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " "); + }, + + rangeFromVisibleTextSearch: function (text, locator) { + if (!text) { + hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator)); + return null; + } + var root = this.highlightTextRoot(); + var nodes = this.highlightTextNodes(root); + if (!nodes.length) { + hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator)); + return null; + } + + var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join(""); + var expected = String(text); + var candidates = []; + var index = fullText.indexOf(expected); + while (index !== -1) { + candidates.push(index); + index = fullText.indexOf(expected, index + 1); + } + if (!candidates.length) { + var lowerFull = fullText.toLowerCase(); + var lowerExpected = expected.toLowerCase(); + index = lowerFull.indexOf(lowerExpected); + while (index !== -1) { + candidates.push(index); + index = lowerFull.indexOf(lowerExpected, index + 1); + } + } + if (!candidates.length && expected.length > 20) { + var partial = expected.substring(0, Math.min(expected.length, 40)); + index = fullText.indexOf(partial); + while (index !== -1) { + candidates.push(index); + index = fullText.indexOf(partial, index + 1); + } + } + if (!candidates.length) { + hlRenderLog( + "webview_text_search_skip reason=no_candidates textLen=" + expected.length + + " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator) + ); + return null; + } + + var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null + ? parseInt(locator.startOffset, 10) + : candidates[0]; + if (!isFinite(preferred)) preferred = candidates[0]; + var best = candidates.reduce(function (currentBest, candidate) { + return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest; + }, candidates[0]); + hlRenderLog( + "webview_text_search_candidate count=" + candidates.length + + " preferred=" + preferred + " best=" + best + + " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator) + ); + return this.rangeFromTextOffsets(root, best, best + expected.length); + }, + + rangeFromLocator: function (locator, text) { + if (!locator) { + hlRenderLog("webview_locator_skip reason=missing_locator"); + return null; + } + if (locator.cfi && String(locator.cfi).charAt(0) === "/") { + hlRenderLog( + "webview_locator_skip reason=structural_cfi_prefers_cfi " + + hlRenderLocatorLabel(locator) + ); + return null; + } + var root = this.highlightTextRoot(); + var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset); + if (range && this.rangeMatchesText(range, text)) { + hlRenderLog( + "webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " + + hlRenderLocatorLabel(locator) + ); + return range; + } + hlRenderLog( + "webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") + + " rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " + + "expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator) + ); + return null; + }, + + rangeFromCfi: function (cfi, text) { + if (!cfi || cfi.indexOf("desktop:") === 0) { + hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120)); + return null; + } + var sourceCfi = String(cfi).split("|")[0]; + const location = window.getNodeAndOffsetFromCfi(sourceCfi); + if (!location || !location.node) { + hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120)); + return null; + } + + let startNode = location.node; + let startOffset = location.offset; + + if (startNode.nodeType === Node.TEXT_NODE) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + walker.currentNode = startNode; + + while (startNode && startOffset >= startNode.nodeValue.length) { + if (startOffset === startNode.nodeValue.length) { + const next = walker.nextNode(); + + if (next) { + startOffset -= startNode.nodeValue.length; + startNode = next; + } else { + break; + } + } else { + startOffset -= startNode.nodeValue.length; + startNode = walker.nextNode(); + } + } + } + + if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) { + const nodeVal = startNode.nodeValue; + const substring = nodeVal.substring(startOffset, startOffset + text.length); + + if (substring !== text && substring.trim() !== text.trim()) { + hlRenderLog( + "webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) + + " startOffset=" + startOffset + + " actual='" + hlRenderPreview(substring, 80) + "'" + + " expected='" + hlRenderPreview(text, 80) + "'" + ); + console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby."); + const foundIndex = nodeVal.indexOf(text); + + if (foundIndex !== -1) { + hlRenderLog( + "webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset + + " newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120) + ); + startOffset = foundIndex; + } else { + const partial = text.substring(0, Math.min(text.length, 20)); + const partialIndex = nodeVal.indexOf(partial); + + if (partialIndex !== -1) { + hlRenderLog( + "webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset + + " newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120) + ); + startOffset = partialIndex; + } + } + } + } + + if (!startNode) { + hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120)); + return null; + } + + const range = document.createRange(); + + if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) { + startOffset = Math.max(0, startNode.nodeValue.length - 1); + } + + range.setStart(startNode, startOffset); + + const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + treeWalker.currentNode = startNode; + + let remainingLen = text.length; + let endNode = treeWalker.currentNode; + let endOffset = startOffset; + + while (remainingLen > 0 && endNode) { + let avail = endNode.nodeValue.length - endOffset; + + if (avail >= remainingLen) { + endOffset += remainingLen; + remainingLen = 0; + } else { + remainingLen -= avail; + endNode = treeWalker.nextNode(); + endOffset = 0; + } + } + + if (!endNode) { + hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120)); + return null; + } + range.setEnd(endNode, endOffset); + if (range.collapsed) { + hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120)); + return null; + } + hlRenderLog( + "webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) + + " text='" + hlRenderPreview(range.toString(), 80) + "'" + ); + return range; + }, + + applyHighlight: function (cfi, text, cssClass, locator) { try { + cssClass = cssClass || "user-highlight-yellow"; var alreadyApplied = false; var spans = document.querySelectorAll(`span[data-cfi]`); for (var i = 0; i < spans.length; i++) { - if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) { + if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) { alreadyApplied = true; break; } } - if (alreadyApplied) return; - - const location = window.getNodeAndOffsetFromCfi(cfi); - if (!location || !location.node) return; - - let startNode = location.node; - let startOffset = location.offset; - - if (startNode.nodeType === Node.TEXT_NODE) { - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - walker.currentNode = startNode; - - while (startNode && startOffset >= startNode.nodeValue.length) { - if (startOffset === startNode.nodeValue.length) { - const next = walker.nextNode(); - - if (next) { - startOffset -= startNode.nodeValue.length; - startNode = next; - } else { - break; - } - } else { - startOffset -= startNode.nodeValue.length; - startNode = walker.nextNode(); - } - } + if (alreadyApplied) { + hlRenderLog( + "webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) + + " textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator) + ); + return; } - // 1. Text Verification / Healing - if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) { - const nodeVal = startNode.nodeValue; - // Check if text matches at exact offset - const substring = nodeVal.substring(startOffset, startOffset + text.length); - - // Allow for some whitespace looseness (trim comparison) - if (substring !== text && substring.trim() !== text.trim()) { - console.log(`$ { - HL_LOG_TAG - } - - : Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `); - - // Try finding the text in the whole node - const foundIndex = nodeVal.indexOf(text); - - if (foundIndex !== -1) { - console.log(`$ { - HL_LOG_TAG - } - - : Found text elsewhere in node. Adjusting offset from $ { - startOffset - } - - to $ { - foundIndex - } - - .`); - startOffset = foundIndex; - } else { - // Simple fuzzy: Try finding first 20 chars - const partial = text.substring(0, Math.min(text.length, 20)); - const partialIndex = nodeVal.indexOf(partial); - - if (partialIndex !== -1) { - console.log(`$ { - HL_LOG_TAG - } - - : Found partial match. Adjusting offset.`); - startOffset = partialIndex; - } - } - } + var hasPreciseLocator = locator && + locator.startOffset !== undefined && locator.startOffset !== null && + locator.endOffset !== undefined && locator.endOffset !== null && + parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10); + hlRenderLog( + "webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) + + " textLen=" + String(text || "").length + + " cssClass=" + cssClass + + " hasPreciseLocator=" + !!hasPreciseLocator + " " + + hlRenderLocatorLabel(locator) + ); + var rangeSource = "locator"; + var sourceCfi = (locator && locator.cfi) || cfi; + var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/"; + var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text); + if (!range && hasSourceCfi) { + rangeSource = "cfi"; + range = this.rangeFromCfi(sourceCfi, text || ""); } - - if (!startNode) return; - - const range = document.createRange(); - - // Set Start - if (startNode.nodeType === Node.TEXT_NODE) { - // Ensure offset is valid - if (startOffset > startNode.nodeValue.length) { - startOffset = Math.max(0, startNode.nodeValue.length - 1); - } + if (!range) { + rangeSource = "locator"; + range = this.rangeFromLocator(locator, text); } - - range.setStart(startNode, startOffset); - - const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - treeWalker.currentNode = startNode; - - let currentNode = treeWalker.currentNode; - let remainingOffset = startOffset; - let remainingLen = text.length; - let endNode = currentNode; - let endOffset = startOffset; - - while (remainingLen > 0 && endNode) { - let avail = endNode.nodeValue.length - endOffset; - - if (avail >= remainingLen) { - endOffset += remainingLen; - remainingLen = 0; - } else { - remainingLen -= avail; - endNode = treeWalker.nextNode(); - endOffset = 0; - } + if (!range && !hasPreciseLocator && !hasSourceCfi) { + rangeSource = "text_search"; + range = this.rangeFromVisibleTextSearch(text || "", locator); } - - if (endNode) { - range.setEnd(endNode, endOffset); - var normalizedRange = this.normalizeRangeBoundaries(range); - this.highlightRangeSafe(normalizedRange, cssClass, cfi); + if (!range) { + hlRenderLog( + "webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) + + " hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator) + ); + return; } + var normalizedRange = this.normalizeRangeBoundaries(range); + this.highlightRangeSafe(normalizedRange, cssClass, cfi); + hlRenderLog( + "webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) + + " source=" + rangeSource + + " renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'" + ); } catch (e) { + hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160)); console.log(e); } }, diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt index f050e92..d46c3d5 100644 --- a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt +++ b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt @@ -15,6 +15,8 @@ fun androidSettingsHubInput( val supportsOssAiKeys = isOssBuild && !isOfflineBuild val featurePolicy = if (isOfflineBuild) { SharedFeaturePolicy.OssOffline + } else if (isOssBuild) { + SharedFeaturePolicy.OssOnline } else { SharedFeaturePolicy.Standard } diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt index 4f0bb68..b97a93e 100644 --- a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt +++ b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt @@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge { val sharedInput = SharedLibraryProjectionInput( state = projectionState.toSharedReaderScreenState( rawBooks = taggedBooks, - dbTags = input.dbTags + dbTags = input.dbTags, + includeReaderAnnotations = false ), booksFromStore = taggedBooks .filterNot { it.bookId.endsWith("_reflow") } @@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge { private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState { return toSharedReaderScreenState( rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles }, - dbTags = projectedState.allTags.ifEmpty { allTags } + dbTags = projectedState.allTags.ifEmpty { allTags }, + includeReaderAnnotations = false ) } diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index 5f4bbee..18780d4 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -21,6 +21,7 @@ package com.aryan.reader import android.os.Build import timber.log.Timber +import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -80,6 +81,18 @@ object AppDestinations { const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" } +fun shouldInterceptAppNavBack( + currentRoute: String?, + hasPreviousBackStackEntry: Boolean, + isCurrentEntryResumed: Boolean +): Boolean { + if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false + return currentRoute != null && + currentRoute != AppDestinations.MAIN_ROUTE && + currentRoute != AppDestinations.PDF_VIEWER_ROUTE && + currentRoute != AppDestinations.EPUB_READER_ROUTE +} + private fun NavHostController.isReadyForBackStackChange(): Boolean { return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED } @@ -166,6 +179,11 @@ fun AppNavigation( val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp( isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE ).dp + val shouldInterceptBack = shouldInterceptAppNavBack( + currentRoute = currentRoute, + hasPreviousBackStackEntry = navController.previousBackStackEntry != null, + isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED + ) LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { @@ -195,6 +213,10 @@ fun AppNavigation( } Box(modifier = Modifier.fillMaxSize()) { + BackHandler(enabled = shouldInterceptBack) { + navController.popBackStackIfReady() + } + NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) { Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") @@ -315,7 +337,7 @@ fun AppNavigation( }, onRenderModeChange = viewModel::setRenderMode, customFonts = customFonts, - onImportFont = viewModel::importFont, + onImportFonts = viewModel::importFonts, viewModel = viewModel ) diff --git a/app/src/main/java/com/aryan/reader/BookReplacementStore.kt b/app/src/main/java/com/aryan/reader/BookReplacementStore.kt new file mode 100644 index 0000000..9252f54 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/BookReplacementStore.kt @@ -0,0 +1,80 @@ +package com.aryan.reader + +import android.content.Context +import androidx.core.content.edit +import com.aryan.reader.shared.ReaderBookReplacementPreferences +import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson +import com.aryan.reader.shared.ReaderWordReplacementEngine +import com.aryan.reader.shared.ReaderWordReplacementRule +import org.jsoup.nodes.Document +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +private const val READER_PREFS_NAME = "reader_prefs" +private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json" + +fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null)) +} + +fun saveBookReplacementPreferences( + context: Context, + preferences: ReaderBookReplacementPreferences, +) { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { + putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences)) + } +} + +internal fun applyBookReplacementsToHtmlDocument( + document: Document, + preferences: ReaderBookReplacementPreferences, + fileId: String?, +): Boolean { + val rules = preferences.activeRulesForFile(fileId) + if (rules.isEmpty()) return false + + var changed = false + + fun rewriteTextNodes(node: Node) { + if (node is TextNode && !node.hasReplacementBlockedAncestor()) { + val original = node.wholeText + val replaced = applyBookReplacementRules(original, rules) + if (replaced != original) { + node.text(replaced) + changed = true + } + return + } + + node.childNodes().forEach(::rewriteTextNodes) + } + + document.body()?.let(::rewriteTextNodes) + return changed +} + +private fun applyBookReplacementRules( + text: String, + rules: List, +): String { + return ReaderWordReplacementEngine.apply( + text = text, + rules = rules, + ).text +} + +private fun TextNode.hasReplacementBlockedAncestor(): Boolean { + var current: Node? = parent() + while (current != null) { + when (current.nodeName().lowercase()) { + "script", + "style", + "noscript" -> return true + } + current = current.parent() + } + return false +} diff --git a/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt b/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt new file mode 100644 index 0000000..7e127fa --- /dev/null +++ b/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt @@ -0,0 +1,400 @@ +package com.aryan.reader + +import androidx.annotation.StringRes +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.ReaderBookReplacementEngine +import com.aryan.reader.shared.ReaderBookReplacementPreferences +import com.aryan.reader.shared.ReaderWordReplacementRule + +private data class BookRuleEditTarget( + val ruleId: String? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookWordReplacementsSheet( + isVisible: Boolean, + bookId: String, + bookTitle: String?, + preferences: ReaderBookReplacementPreferences, + onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var editTarget by remember(bookId) { mutableStateOf(null) } + val rules = preferences.rulesForFile(bookId) + val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 720.dp) + .imePadding() + .padding(horizontal = 20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.menu_book_word_replacements), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.heightIn(max = 560.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + TextButton( + onClick = { editTarget = BookRuleEditTarget() }, + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.book_replacements_add_rule)) + } + } + if (editTarget != null) { + item { + BookRuleEditorCard( + seedRule = editingRule, + onCancel = { editTarget = null }, + onSave = { rule -> + val updatedRules = if (editingRule == null) { + rules + rule + } else { + rules.map { if (it.id == editingRule.id) rule else it } + } + onPreferencesChange(preferences.withFileRules(bookId, updatedRules)) + editTarget = null + }, + ) + } + } + item { + BookReplacementRuleList( + rules = rules, + emptyTextRes = R.string.book_replacements_empty, + onToggle = { rule, enabled -> + onPreferencesChange( + preferences.withFileRules( + bookId, + rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it }, + ), + ) + }, + onEdit = { editTarget = BookRuleEditTarget(it.id) }, + onDelete = { rule -> + onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id })) + }, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun BookRuleEditorCard( + seedRule: ReaderWordReplacementRule?, + onCancel: () -> Unit, + onSave: (ReaderWordReplacementRule) -> Unit, +) { + val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() } + val initial = seedRule ?: ReaderWordReplacementRule( + id = draftRuleId, + from = "", + to = "", + ) + var from by remember(initial.id) { mutableStateOf(initial.from) } + var to by remember(initial.id) { mutableStateOf(initial.to) } + var enabled by remember(initial.id) { mutableStateOf(initial.enabled) } + var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) } + var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) } + var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) } + val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default) + var previewInput by remember(initial.id, defaultPreviewInput) { + mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput) + } + + val draft = ReaderWordReplacementRule( + id = initial.id, + from = from, + to = to, + enabled = enabled, + isRegex = isRegex, + matchCase = matchCase, + wholeWord = wholeWord, + ) + val validation = ReaderBookReplacementEngine.validate(draft) + val previewOutput = if (validation.isValid) { + ReaderBookReplacementEngine.apply( + text = previewInput, + preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))), + fileId = "preview", + ).text + } else { + previewInput + } + + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + OutlinedTextField( + value = from, + onValueChange = { from = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.tts_replacements_label_replace)) }, + singleLine = !isRegex, + isError = !validation.isValid, + supportingText = if (validation.message != null) { + { Text(validation.message.orEmpty()) } + } else { + null + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + ) + OutlinedTextField( + value = to, + onValueChange = { to = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.book_replacements_label_with)) }, + singleLine = !isRegex, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + FilterChip( + selected = enabled, + onClick = { enabled = !enabled }, + label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) }, + leadingIcon = if (enabled) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + } + item { + FilterChip( + selected = isRegex, + onClick = { isRegex = !isRegex }, + label = { Text(stringResource(R.string.tts_replacements_chip_regex)) }, + ) + } + item { + FilterChip( + selected = wholeWord, + onClick = { wholeWord = !wholeWord }, + label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) }, + ) + } + item { + FilterChip( + selected = matchCase, + onClick = { matchCase = !matchCase }, + label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) }, + ) + } + } + OutlinedTextField( + value = previewInput, + onValueChange = { previewInput = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) }, + minLines = 2, + ) + Text( + text = previewOutput, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onSave(draft) }, + enabled = validation.isValid, + ) { + Text(stringResource(R.string.action_save)) + } + } + } + } +} + +@Composable +private fun BookReplacementRuleList( + rules: List, + @StringRes emptyTextRes: Int, + onToggle: (ReaderWordReplacementRule, Boolean) -> Unit, + onEdit: (ReaderWordReplacementRule) -> Unit, + onDelete: (ReaderWordReplacementRule) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.tts_replacements_rules), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (rules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(emptyTextRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + rules.forEach { rule -> + val emptyLabel = stringResource(R.string.book_replacements_empty_replacement) + ListItem( + headlineContent = { + Text( + text = rule.summaryText(emptyLabel), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text(rule.optionSummary()) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = rule.enabled, + onCheckedChange = { onToggle(rule, it) }, + ) + IconButton(onClick = { onEdit(rule) }) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit)) + } + IconButton(onClick = { onDelete(rule) }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + }, + ) + } + } +} + +private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String { + val replacement = to.ifBlank { emptyLabel } + return "$from -> $replacement" +} + +@Composable +private fun ReaderWordReplacementRule.optionSummary(): String { + val regexLabel = stringResource(R.string.tts_replacements_chip_regex) + val plainTextLabel = stringResource(R.string.tts_replacements_plain_text) + val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word) + val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive) + val parts = buildList { + add(if (isRegex) regexLabel else plainTextLabel) + if (wholeWord) add(wholeWordLabel) + if (matchCase) add(caseSensitiveLabel) + } + return parts.joinToString(" - ") +} + +private fun newBookReplacementRuleId(): String { + return "book_rule_${System.currentTimeMillis()}" +} diff --git a/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt b/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt new file mode 100644 index 0000000..8b92be3 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt @@ -0,0 +1,51 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookMetadata +import com.aryan.reader.data.RecentFileItem + +internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean { + return type in EPUB_READER_FILE_TYPES && + (bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank()) +} + +internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem { + if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) { + return this + } + val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) { + remote.bookmarksJson + } else { + bookmarksJson + } + val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) { + remote.highlightsJson + } else { + highlightsJson + } + if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this + return copy( + bookmarksJson = nextBookmarks, + highlightsJson = nextHighlights + ) +} + +private fun BookMetadata.isEpubReaderMetadata(): Boolean { + val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false + return remoteType in EPUB_READER_FILE_TYPES +} + +internal fun String?.hasCloudAnnotationPayload(): Boolean { + val normalized = this?.trim().orEmpty() + return normalized.isNotEmpty() && normalized != "[]" +} + +internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean { + val existingNormalized = existing?.trim().orEmpty() + val incomingNormalized = incoming.trim() + if (existingNormalized == incomingNormalized) return true + return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty() +} + +private fun String.isAnnotationJsonEmpty(): Boolean { + return isBlank() || this == "[]" +} diff --git a/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt b/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt new file mode 100644 index 0000000..2976b8c --- /dev/null +++ b/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt @@ -0,0 +1,75 @@ +package com.aryan.reader + +import java.io.File + +internal data class AndroidPdfCloudSidecarState( + val hasInk: Boolean, + val inkTimestamp: Long, + val hasDeletedInk: Boolean = false, + val deletedInkTimestamp: Long = 0L, + val hasRichText: Boolean, + val richTextTimestamp: Long, + val hasLayout: Boolean, + val layoutTimestamp: Long, + val hasTextBoxes: Boolean, + val textBoxesTimestamp: Long, + val hasHighlights: Boolean, + val highlightsTimestamp: Long +) { + val hasAnnotationPayload: Boolean + get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights + + val annotationPayloadTimestamp: Long + get() = maxOf( + inkTimestamp.takeIf { hasInk } ?: 0L, + deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L, + richTextTimestamp.takeIf { hasRichText } ?: 0L, + textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L, + highlightsTimestamp.takeIf { hasHighlights } ?: 0L + ) + + val bundleTimestamp: Long + get() = if (hasAnnotationPayload) { + maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L) + } else { + 0L + } +} + +internal fun shouldUploadLocalPdfCloudAnnotations( + localSidecars: AndroidPdfCloudSidecarState, + remoteHasAnnotations: Boolean, + remoteAnnotationModifiedTimestamp: Long +): Boolean { + return localSidecars.hasAnnotationPayload && + (!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp) +} + +internal fun shouldDownloadRemotePdfCloudAnnotations( + localSidecars: AndroidPdfCloudSidecarState, + localAnnotationsShouldUpload: Boolean, + remoteHasAnnotations: Boolean, + remoteAnnotationModifiedTimestamp: Long +): Boolean { + if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false + return !localSidecars.hasAnnotationPayload || + remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp +} + +internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean { + val file = this ?: return false + if (!file.isFile || file.length() <= 0L) return false + val trimmed = runCatching { file.readText().trim() }.getOrDefault("") + return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}" +} + +internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) { + if (timestamp <= 0L) return + files.forEach { file -> + if (file?.exists() == true) { + file.setLastModified(timestamp) + } + } +} + +internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json" diff --git a/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt b/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt new file mode 100644 index 0000000..8702480 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt @@ -0,0 +1,70 @@ +package com.aryan.reader + +import android.util.Log +import com.aryan.reader.data.BookMetadata +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp +import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp +import timber.log.Timber + +internal const val CloudSyncTraceTag = "EpistemeCloudSync" +internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations" + +internal fun logCloudSyncTrace(message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.d(CloudSyncTraceTag, text) + Timber.tag(CloudSyncTraceTag).d(text) +} + +internal fun logCloudSyncError(error: Throwable, message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.e(CloudSyncTraceTag, text, error) + Timber.tag(CloudSyncTraceTag).e(error, text) +} + +internal fun logCloudAnnotationSyncTrace(message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.d(CloudAnnotationSyncTraceTag, text) + Timber.tag(CloudAnnotationSyncTraceTag).d(text) +} + +internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.e(CloudAnnotationSyncTraceTag, text, error) + Timber.tag(CloudAnnotationSyncTraceTag).e(error, text) +} + +internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " + + "contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " + + "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " + + "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " + + "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " + + "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun String?.cloudSyncPreview(maxLength: Int = 80): String { + val value = this ?: return "null" + return if (value.length <= maxLength) value else value.take(maxLength) + "..." +} + +internal fun String?.cloudSyncAnnotationSummary(): String { + val value = this?.trim() ?: return "null" + return when { + value.isEmpty() -> "blank" + value == "[]" -> "empty" + else -> "present(${value.length})" + } +} diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 3f071d5..9fb3767 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -2949,7 +2949,14 @@ private fun ThemeGridItem( Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(8.dp)) - Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = theme.name, + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { onThemeSelected(theme.id) } + ) if (theme.isCustom && onEdit != null && onDelete != null) { Spacer(modifier = Modifier.height(6.dp)) diff --git a/app/src/main/java/com/aryan/reader/FileHasher.kt b/app/src/main/java/com/aryan/reader/FileHasher.kt index 7d8e436..2142093 100644 --- a/app/src/main/java/com/aryan/reader/FileHasher.kt +++ b/app/src/main/java/com/aryan/reader/FileHasher.kt @@ -21,6 +21,7 @@ package com.aryan.reader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.InputStream import java.security.MessageDigest @@ -50,9 +51,8 @@ object FileHasher { } hexString.toString() } catch (e: Exception) { - // In a real app, you'd want to log this error - e.printStackTrace() + Timber.e(e, "Failed to calculate SHA-256 hash") null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 97bdde9..7d68558 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -72,45 +72,27 @@ class FolderSyncWorker( val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - val jsonString = prefs.getString("synced_folders_list_json", null) - val folders = mutableListOf>>() - - if (jsonString != null) { - try { - val array = org.json.JSONArray(jsonString) - for (i in 0 until array.length()) { - val obj = array.getJSONObject(i) - val uri = obj.getString("uri") - val allowedFileTypes = mutableSetOf() - if (obj.has("allowedFileTypes")) { - val typesArray = obj.getJSONArray("allowedFileTypes") - for (j in 0 until typesArray.length()) { - try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {} - } - } else { - allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES) - } - folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES })) - } - } catch (e: Exception) { Timber.e(e) } - } else { - val single = prefs.getString("synced_folder_uri", null) - if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES)) - } + val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null) + val folders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = jsonString, + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), + syncableTypes = ANDROID_SYNCABLE_FILE_TYPES + ) if (folders.isEmpty()) { ReaderPerfLog.w("FolderSync worker aborted: no linked folders") return Result.success() } + val enabledFolders = folders.filter { it.localSyncEnabled } val foldersToProcess = if (targetFolderUri.isNullOrBlank()) { - folders + enabledFolders } else { - folders.filter { it.first == targetFolderUri } + enabledFolders.filter { it.uriString == targetFolderUri } } if (foldersToProcess.isEmpty()) { - ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri") + ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri") return Result.success() } @@ -123,8 +105,8 @@ class FolderSyncWorker( syncMutex.withLock { var allSuccess = true - for ((uriString, allowedTypes) in foldersToProcess) { - val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly) + for (folderConfig in foldersToProcess) { + val success = performSyncForFolder(folderConfig, isMetadataOnly) if (!success) allSuccess = false } @@ -132,13 +114,14 @@ class FolderSyncWorker( try { val array = org.json.JSONArray(jsonString) val now = System.currentTimeMillis() + val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString } for (i in 0 until array.length()) { val obj = array.getJSONObject(i) - if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) { + if (obj.optString("uri") in processedUris) { obj.put("lastScanTime", now) } } - prefs.edit { putString("synced_folders_list_json", array.toString()) } + prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) } } catch (_: Exception) {} } @@ -153,7 +136,9 @@ class FolderSyncWorker( } } - private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set, metadataOnly: Boolean): Boolean { + private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean { + val folderUriString = folderConfig.uriString + val allowedFileTypes = folderConfig.allowedFileTypes if (folderUriString.isBlank()) return true val folderUri = folderUriString.toUri() val folderStart = ReaderPerfLog.nowNanos() @@ -235,9 +220,10 @@ class FolderSyncWorker( val nowMillis = System.currentTimeMillis() val folder = SyncedFolder( uriString = folderUriString, - name = documentTree.name ?: "Local Folder", + name = documentTree.name ?: folderConfig.name, lastScanTime = nowMillis, - allowedFileTypes = allowedFileTypes + allowedFileTypes = allowedFileTypes, + localSyncEnabled = true ) val sharedState = SharedReaderScreenState( rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() }, @@ -564,7 +550,8 @@ class FolderSyncWorker( lastPageIndex = lastPage, readerPosition = readerPositionOrNull(), readerBookmarks = parseReaderBookmarks(), - readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson), + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp ) } @@ -613,8 +600,8 @@ class FolderSyncWorker( lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex, lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage, lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi, - locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, - locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset, + locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, + locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset, progressPercentage = progressPercentage, isRecent = isRecent, isAvailable = true, @@ -637,6 +624,7 @@ class FolderSyncWorker( originalDescription = originalDescription, folderTextMetadataParsed = folderTextMetadataParsed, folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false, + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp, tags = existing?.tags.orEmpty() ) } @@ -720,18 +708,12 @@ class FolderSyncWorker( private fun isFolderStillLinked(folderUriString: String): Boolean { val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - val jsonString = prefs.getString("synced_folders_list_json", null) - if (jsonString != null) { - return try { - val array = org.json.JSONArray(jsonString) - (0 until array.length()).any { index -> - array.getJSONObject(index).optString("uri") == folderUriString - } - } catch (_: Exception) { - false - } - } - return prefs.getString("synced_folder_uri", null) == folderUriString + return SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), + folderUriString = folderUriString, + syncableTypes = ANDROID_SYNCABLE_FILE_TYPES + ) } private fun getFileType(name: String, mimeType: String?): FileType? { diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt index 4e86a24..7c1caad 100644 --- a/app/src/main/java/com/aryan/reader/FontsScreen.kt +++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt @@ -3,8 +3,11 @@ package com.aryan.reader +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -46,6 +51,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState 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 @@ -80,37 +86,67 @@ fun FontsScreen( val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) - // Dialog state - var showDeleteDialog by remember { mutableStateOf(false) } - var fontToDelete by remember { mutableStateOf(null) } + var fontsPendingDelete by remember { mutableStateOf>(emptyList()) } var showGoogleFontsSheet by remember { mutableStateOf(false) } var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) } + var selectedFontIds by remember { mutableStateOf>(emptySet()) } - val pickFontLauncher = rememberFilePickerLauncher { uris -> - uris.firstOrNull()?.let { viewModel.importFont(it) } + val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts) + val fontMimeTypes = remember { supportedFontMimeTypes() } + val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } } + val selectedFonts = remember(fonts, selectedFontIds) { + fonts.filter { it.id in selectedFontIds } + } + val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty() + + LaunchedEffect(fonts) { + selectedFontIds = selectedFontIds.intersect(allFontIds) } - val fontMimeTypes = arrayOf( - "font/ttf", "font/otf", "font/woff2", - "application/x-font-ttf", "application/x-font-otf", - "application/font-woff2", "application/vnd.ms-opentype", - "application/x-font-opentype" - ) + BackHandler(enabled = isFontSelectionMode) { + selectedFontIds = emptySet() + } Scaffold( modifier = Modifier.statusBarsPadding(), topBar = { - CustomTopAppBar( - title = { Text(stringResource(R.string.custom_fonts)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + if (isFontSelectionMode) { + ContextualTopAppBar( + selectedItemCount = selectedFonts.size, + onNavIconClick = { selectedFontIds = emptySet() }, + onSelectAllClick = { + selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) { + emptySet() + } else { + allFontIds + } + }, + onDeleteClick = { + if (selectedFonts.isNotEmpty()) { + fontsPendingDelete = selectedFonts + } } - } - ) + ) + } else { + CustomTopAppBar( + title = { Text(stringResource(R.string.custom_fonts)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) { + IconButton(onClick = { selectedFontIds = allFontIds }) { + Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all)) + } + } + } + ) + } }, floatingActionButton = { - if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) { + if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -139,7 +175,10 @@ fun FontsScreen( Column(modifier = Modifier.fillMaxSize()) { SharedFontSettingsTabs( selectedSection = selectedSection, - onSectionChange = { selectedSection = it }, + onSectionChange = { + selectedFontIds = emptySet() + selectedSection = it + }, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) ) @@ -166,9 +205,13 @@ fun FontsScreen( items(fonts, key = { it.id }) { font -> FontListItem( font = font, + isSelected = font.id in selectedFontIds, + isSelectionMode = isFontSelectionMode, + onSelectionToggle = { + selectedFontIds = selectedFontIds.toggle(font.id) + }, onDelete = { - fontToDelete = font - showDeleteDialog = true + fontsPendingDelete = listOf(font) } ) } @@ -208,17 +251,17 @@ fun FontsScreen( } } - if (showDeleteDialog && fontToDelete != null) { - DeleteFontConfirmationDialog( - fontName = fontToDelete!!.displayName, + if (fontsPendingDelete.isNotEmpty()) { + DeleteFontsConfirmationDialog( + fonts = fontsPendingDelete, onConfirm = { - fontToDelete?.let { viewModel.deleteFont(it.id) } - showDeleteDialog = false - fontToDelete = null + val pendingIds = fontsPendingDelete.map { it.id } + viewModel.deleteFonts(pendingIds) + selectedFontIds = selectedFontIds - pendingIds.toSet() + fontsPendingDelete = emptyList() }, onDismiss = { - showDeleteDialog = false - fontToDelete = null + fontsPendingDelete = emptyList() } ) } @@ -388,9 +431,13 @@ fun GoogleFontsBottomSheet( } // Existing unchanged components +@OptIn(ExperimentalFoundationApi::class) @Composable fun FontListItem( font: CustomFontEntity, + isSelected: Boolean, + isSelectionMode: Boolean, + onSelectionToggle: () -> Unit, onDelete: () -> Unit ) { val customTypeface = remember(font.path) { @@ -402,8 +449,23 @@ fun FontListItem( } Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (isSelectionMode) { + onSelectionToggle() + } + }, + onLongClick = onSelectionToggle + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surface + } + ) ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -411,17 +473,27 @@ fun FontListItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + if (isSelectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle() }, + modifier = Modifier.padding(end = 8.dp) + ) + } Text( text = font.displayName, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) - IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.error - ) + if (!isSelectionMode) { + IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error + ) + } } } @@ -476,15 +548,32 @@ private fun List.toSharedCustomFontItems(): List, onConfirm: () -> Unit, onDismiss: () -> Unit ) { + val isSingleFont = fonts.size == 1 AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_delete_font)) }, - text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) }, + title = { + Text( + if (isSingleFont) { + stringResource(R.string.dialog_delete_font) + } else { + stringResource(R.string.dialog_delete_fonts) + } + ) + }, + text = { + Text( + if (isSingleFont) { + stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName) + } else { + stringResource(R.string.dialog_delete_fonts_desc, fonts.size) + } + ) + }, confirmButton = { TextButton( onClick = onConfirm, @@ -498,3 +587,7 @@ fun DeleteFontConfirmationDialog( } ) } + +private fun Set.toggle(id: String): Set { + return if (id in this) this - id else this + id +} diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 926488a..5dce10d 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -436,7 +437,7 @@ fun HomeScreen( onRefresh = { viewModel.refreshLibrary() }, isRefreshing = uiState.isRefreshing, isSyncEnabled = uiState.isSyncEnabled, - hasSyncedFolder = uiState.syncedFolders.isNotEmpty(), + hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled }, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) } @@ -767,7 +768,8 @@ private fun RecentFilesGrid( modifier = Modifier.size(16.dp) ) } - } + }, + modifier = Modifier.testTag("HomeTab_${tab.bookId}") ) } } @@ -817,6 +819,7 @@ fun RecentFileCard( androidx.compose.material3.ElevatedCard( modifier = modifier + .testTag("HomeRecentFileCard_${item.bookId}") .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .then( if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) @@ -1485,7 +1488,7 @@ private fun AppDrawerContent( Spacer(modifier = Modifier.weight(1f)) // legal links - if (uiState.currentUser != null && !isOss) { + if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) { val uriHandler = LocalUriHandler.current val baseStyle = MaterialTheme.typography.labelMedium var scaledTextStyle by remember { mutableStateOf(baseStyle) } diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt index 6e5c3f1..690462e 100644 --- a/app/src/main/java/com/aryan/reader/LibraryModels.kt +++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt @@ -16,6 +16,7 @@ typealias ShelfType = com.aryan.reader.shared.ShelfType internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID) +internal val COMIC_ARCHIVE_FILE_TYPES = SharedFileCapabilities.comicArchiveTypes 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 diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 90370a0..ae267c1 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -135,15 +137,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController +import coil.ImageLoader import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil.decode.SvgDecoder import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.TagEntity import com.aryan.reader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsCatalog import com.aryan.reader.opds.OpdsDownloadState import com.aryan.reader.opds.OpdsEntry +import com.aryan.reader.opds.OpdsRepository import com.aryan.reader.opds.OpdsViewModel +import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import com.aryan.reader.shared.opds.SharedOpdsLocalBookMatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -325,6 +331,7 @@ fun LibraryScreen( onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) }, syncedFolders = uiState.syncedFolders, onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, + onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled, onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, downloadingBookIds = uiState.downloadingBookIds, lastFolderScanTime = uiState.lastFolderScanTime, @@ -590,6 +597,7 @@ fun LibraryScreenContent( isRefreshing: Boolean, syncedFolders: List, onRemoveFolderClick: (SyncedFolder) -> Unit, + onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, onOpdsBookDownloaded: (Uri, String) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onDeleteCatalogStreams: (String) -> Unit, @@ -666,7 +674,8 @@ fun LibraryScreenContent( modifier = Modifier .weight(1f) .padding(vertical = 4.dp) - .focusRequester(searchFocusRequester), + .focusRequester(searchFocusRequester) + .testTag("LibrarySearchTextField"), singleLine = true, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, @@ -693,7 +702,10 @@ fun LibraryScreenContent( Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter)) } Box { - TextButton(onClick = { showSortMenu = true }) { + TextButton( + onClick = { showSortMenu = true }, + modifier = Modifier.testTag("LibrarySortButton") + ) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = stringResource(R.string.content_desc_sort), @@ -822,7 +834,9 @@ fun LibraryScreenContent( text = { Text(stringResource(R.string.fab_new_shelf)) }, icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) }, onClick = onNewShelfClick, - modifier = Modifier.padding(16.dp) + modifier = Modifier + .padding(16.dp) + .testTag("LibraryNewShelfFab") ) } } @@ -888,6 +902,7 @@ fun LibraryScreenContent( allRecentFiles = rawLibraryFiles, onAddFolderClick = onSelectSyncFolderClick, onRemoveFolderClick = onRemoveFolderClick, + onFolderLocalSyncChange = onFolderLocalSyncChange, onEditFolderFiltersClick = onEditFolderFiltersClick, onScanNowClick = onScanNowClick, onSyncMetadataClick = onSyncMetadataClick, @@ -1140,7 +1155,8 @@ private fun ShelfDetailScreen( modifier = Modifier .weight(1f) .padding(vertical = 4.dp) - .focusRequester(searchFocusRequester), + .focusRequester(searchFocusRequester) + .testTag("ShelfSearchTextField"), singleLine = true, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, @@ -1193,7 +1209,10 @@ private fun ShelfDetailScreen( }, actions = { Box { - TextButton(onClick = { showSortMenu = true }) { + TextButton( + onClick = { showSortMenu = true }, + modifier = Modifier.testTag("ShelfSortButton") + ) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = stringResource(R.string.content_desc_sort), @@ -1375,7 +1394,10 @@ private fun AddBooksModeScreen( }, actions = { Box { - TextButton(onClick = { showSortMenu = true }) { + TextButton( + onClick = { showSortMenu = true }, + modifier = Modifier.testTag("AddBooksSortButton") + ) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = stringResource(R.string.content_desc_sort), @@ -1581,6 +1603,7 @@ private fun ShelfListItem( ), modifier = Modifier .fillMaxWidth() + .testTag("ShelfItem_${shelf.id}") .then( if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) else Modifier @@ -1659,6 +1682,7 @@ private fun LibraryListItem( ), modifier = Modifier .fillMaxWidth() + .testTag("LibraryBookItem_${item.bookId}") .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .then( if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) @@ -1929,12 +1953,15 @@ private fun FolderSyncScreen( allRecentFiles: List, onAddFolderClick: () -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit, + onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, onScanNowClick: () -> Unit, onSyncMetadataClick: () -> Unit, isLoading: Boolean ) { var editingFolder by remember { mutableStateOf(null) } + var disablingFolder by remember { mutableStateOf(null) } + val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled } val folderStatsByUri = remember(allRecentFiles) { allRecentFiles .asSequence() @@ -1973,7 +2000,7 @@ private fun FolderSyncScreen( ) { FilledTonalButton( onClick = onScanNowClick, - enabled = !isLoading, + enabled = !isLoading && hasEnabledSyncFolders, modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.small ) { @@ -1988,7 +2015,7 @@ private fun FolderSyncScreen( androidx.compose.material3.OutlinedButton( onClick = onSyncMetadataClick, - enabled = !isLoading, + enabled = !isLoading && hasEnabledSyncFolders, modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.small ) { @@ -2016,6 +2043,13 @@ private fun FolderSyncScreen( folder = folder, stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty, onRemoveClick = onRemoveFolderClick, + onLocalSyncToggleClick = { selectedFolder -> + if (selectedFolder.localSyncEnabled) { + disablingFolder = selectedFolder + } else { + onFolderLocalSyncChange(selectedFolder, true, false) + } + }, onEditFiltersClick = { editingFolder = folder } ) } @@ -2033,6 +2067,46 @@ private fun FolderSyncScreen( onDismiss = { editingFolder = null } ) } + + disablingFolder?.let { folder -> + AlertDialog( + onDismissRequest = { disablingFolder = null }, + title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) }, + text = { + Text( + stringResource( + R.string.dialog_disable_folder_local_sync_desc, + LOCAL_FOLDER_SYNC_DATA_DIR + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + onFolderLocalSyncChange(folder, false, true) + disablingFolder = null + } + ) { + Text(stringResource(R.string.action_disable_remove_sync_data)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { disablingFolder = null }) { + Text(stringResource(R.string.action_cancel)) + } + TextButton( + onClick = { + onFolderLocalSyncChange(folder, false, false) + disablingFolder = null + } + ) { + Text(stringResource(R.string.action_disable_keep_sync_data)) + } + } + } + ) + } } private data class FolderFileStats( @@ -2050,6 +2124,7 @@ private fun FolderCard( folder: SyncedFolder, stats: FolderFileStats, onRemoveClick: (SyncedFolder) -> Unit, + onLocalSyncToggleClick: (SyncedFolder) -> Unit, onEditFiltersClick: (SyncedFolder) -> Unit ) { var showMenu by remember { mutableStateOf(false) } @@ -2075,13 +2150,22 @@ private fun FolderCard( tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(12.dp)) - Text( - text = folder.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = folder.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!folder.localSyncEnabled) { + Text( + text = stringResource(R.string.folder_local_sync_disabled), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } } Box { @@ -2096,6 +2180,21 @@ private fun FolderCard( onEditFiltersClick(folder) } ) + DropdownMenuItem( + text = { + Text( + if (folder.localSyncEnabled) { + stringResource(R.string.menu_disable_folder_local_sync) + } else { + stringResource(R.string.menu_enable_folder_local_sync) + } + ) + }, + onClick = { + showMenu = false + onLocalSyncToggleClick(folder) + } + ) DropdownMenuItem( text = { Text(stringResource(R.string.menu_remove_folder)) }, onClick = { @@ -2377,6 +2476,7 @@ fun OpdsTab( val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() val downloadingState = uiState.downloadingState val context = LocalContext.current + val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog) var selectedEntry by remember { mutableStateOf(null) } var showCatalogDialog by remember { mutableStateOf(false) } var editingCatalog by remember { mutableStateOf(null) } @@ -2601,6 +2701,7 @@ fun OpdsTab( entry = entry, localLibraryFiles = localLibraryFiles, downloadState = downloadingState[entry.id], + coverImageLoader = coverImageLoader, onDownloadClick = { acquisition -> opdsViewModel.downloadBook( entry, acquisition, context @@ -2649,6 +2750,7 @@ fun OpdsTab( entry = selectedEntry!!, localLibraryFiles = localLibraryFiles, downloadState = downloadingState[selectedEntry!!.id], + coverImageLoader = coverImageLoader, onDownloadFormat = { acquisition -> opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri -> onBookDownloaded(downloadedUri, selectedEntry!!.title) @@ -2776,6 +2878,29 @@ fun OpdsTab( } } +@Composable +private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader { + val context = LocalContext.current.applicationContext + val username = catalog?.username + val password = catalog?.password + val imageLoader = remember(context, username, password) { + ImageLoader.Builder(context) + .okHttpClient { + OpdsRepository.sharedHttpClient.newBuilder() + .authenticator(OpdsRepository.OpdsAuthenticator(username, password)) + .build() + } + .components { + add(SvgDecoder.Factory()) + } + .build() + } + DisposableEffect(imageLoader) { + onDispose { imageLoader.shutdown() } + } + return imageLoader +} + @Composable fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) { Surface( @@ -2853,13 +2978,20 @@ fun OpdsBookCard( entry: OpdsEntry, localLibraryFiles: List, downloadState: OpdsDownloadState?, + coverImageLoader: ImageLoader, onDownloadClick: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, onClick: () -> Unit ) { val libraryItem = remember(entry, localLibraryFiles) { - localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) } + SharedOpdsLocalBookMatcher.find( + entry = entry, + books = localLibraryFiles, + title = { it.title }, + displayName = { it.displayName }, + path = { it.uriString } + ) } val isDownloading = downloadState?.isDownloading == true val progress = downloadState?.progress @@ -2878,6 +3010,7 @@ fun OpdsBookCard( AsyncImage( model = entry.coverUrl, contentDescription = null, + imageLoader = coverImageLoader, contentScale = ContentScale.Crop, modifier = Modifier .size(width = 70.dp, height = 100.dp) @@ -2984,6 +3117,7 @@ fun OpdsBookDetailsSheet( entry: OpdsEntry, localLibraryFiles: List, downloadState: OpdsDownloadState?, + coverImageLoader: ImageLoader, onDownloadFormat: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, @@ -2992,7 +3126,13 @@ fun OpdsBookDetailsSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val libraryItem = remember(entry, localLibraryFiles) { - localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) } + SharedOpdsLocalBookMatcher.find( + entry = entry, + books = localLibraryFiles, + title = { it.title }, + displayName = { it.displayName }, + path = { it.uriString } + ) } val isDownloading = downloadState?.isDownloading == true val progress = downloadState?.progress @@ -3012,6 +3152,7 @@ fun OpdsBookDetailsSheet( AsyncImage( model = entry.coverUrl, contentDescription = null, + imageLoader = coverImageLoader, contentScale = ContentScale.Crop, modifier = Modifier .size(width = 110.dp, height = 160.dp) diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 654ec84..4a7b4bd 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -62,12 +62,15 @@ import com.aryan.reader.data.FirestoreRepository import com.aryan.reader.data.FontMetadata import com.aryan.reader.data.FontsRepository import com.aryan.reader.data.GoogleDriveRepository +import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.PurchaseEntity import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RemoteConfigRepository import com.aryan.reader.data.ShelfMetadata import com.aryan.reader.data.TagEntity +import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp +import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp import com.aryan.reader.data.getUri import com.aryan.reader.data.toBookMetadata import com.aryan.reader.data.toRecentFileItem @@ -109,6 +112,11 @@ import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.SharedImportOutcomeCounts import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec +import com.aryan.reader.shared.shouldApplyRemoteCloudBookMetadataUpdate +import com.aryan.reader.shared.shouldDownloadRemoteCloudBookContent +import com.aryan.reader.shared.shouldUploadLocalCloudBookContent +import com.aryan.reader.shared.shouldUploadLocalCloudBookMetadataUpdate +import com.aryan.reader.shared.sharedCloudBookContentFileName import com.aryan.reader.shared.AppAction as SharedAppAction import com.aryan.reader.shared.LibraryAction as SharedLibraryAction import kotlinx.coroutines.CompletableDeferred @@ -132,7 +140,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -163,7 +170,14 @@ private data class CachedSpeechBubble( val maskBitmap: Bitmap? ) +private data class PendingExternalFileRemoval( + val bookId: String, + val uriString: String? +) + private const val BANNER_AUTO_DISMISS_MILLIS = 3_000L +private const val CLOUD_CONTENT_RETRY_DELAY_MILLIS = 10_000L +private const val CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS = 1_500L @kotlin.OptIn(ExperimentalSerializationApi::class) @UnstableApi @@ -212,6 +226,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private var bannerDismissGeneration = 0L private var pendingSwitchDeferred: CompletableDeferred? = null private var externalOpenedBookId: String? = null + private var cloudContentRetryJob: Job? = null + private val cloudMetadataUploadJobs = ConcurrentHashMap() private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null private var speechBubbleDetector: ISpeechBubbleDetector? = null @@ -595,8 +611,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val existingItem = recentFilesRepository.getFileByBookId(hash) if (existingItem != null) { - Timber.i("Book with ID: $hash already exists. Skipping import.") - return null + val pendingRemoval = pendingExternalFileRemovals() + .firstOrNull { it.bookId == hash } + if (pendingRemoval != null) { + deletePendingExternalFileRemoval( + pendingRemoval.copy(uriString = pendingRemoval.uriString ?: existingItem.uriString) + ) + } else { + Timber.i("Book with ID: $hash already exists. Skipping import.") + return null + } } val fileName = displayName ?: "" @@ -1191,11 +1215,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - if (_internalState.value.syncedFolders.isNotEmpty()) { + if (_internalState.value.syncedFolders.any { it.localSyncEnabled }) { triggerFolderSyncWorker(metadataOnly = false, showFeedback = false) } sweepOrphanedCache() + cleanupPendingExternalFileRemovals() restoreReaderSessionIfNeeded() viewModelScope.launch { billingClientWrapper.initializeConnection() } @@ -1225,6 +1250,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (_internalState.value.isSyncEnabled) { viewModelScope.launch { + logCloudSyncTrace { + "android.startup.sync_check user=${newUserData.uid} isSyncEnabled=${_internalState.value.isSyncEnabled}" + } Timber.tag("AnnotationSync").d( "Startup: Pro user & Sync enabled. Initiating cloud sync." ) @@ -1232,6 +1260,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (googleDriveRepository.hasDrivePermissions(appContext)) { syncWithCloud(showBanner = false) } else { + logCloudSyncTrace { "android.startup.sync_skip reason=missing_drive_permissions user=${newUserData.uid}" } Timber.tag("AnnotationSync").d( "Startup: Sync skipped. Missing Drive permissions." ) @@ -1276,6 +1305,119 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun pendingExternalFileRemovals(): List { + return prefs.getStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, emptySet()) + .orEmpty() + .mapNotNull(::decodePendingExternalFileRemoval) + .distinctBy { it.bookId } + } + + private fun markPendingExternalFileRemoval(bookId: String, uriString: String?) { + if (bookId.isBlank()) return + val removalsByBookId = pendingExternalFileRemovals() + .associateBy { it.bookId } + .toMutableMap() + removalsByBookId[bookId] = PendingExternalFileRemoval(bookId, uriString) + writePendingExternalFileRemovals(removalsByBookId.values) + } + + private fun clearPendingExternalFileRemovals(bookIds: Set) { + if (bookIds.isEmpty()) return + val remaining = pendingExternalFileRemovals().filterNot { it.bookId in bookIds } + writePendingExternalFileRemovals(remaining) + } + + private fun writePendingExternalFileRemovals(removals: Collection) { + val encoded = removals + .filter { it.bookId.isNotBlank() } + .mapTo(mutableSetOf(), ::encodePendingExternalFileRemoval) + prefs.edit(commit = true) { + if (encoded.isEmpty()) { + remove(KEY_PENDING_EXTERNAL_FILE_REMOVALS) + } else { + putStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, encoded) + } + } + } + + private fun cleanupPendingExternalFileRemovals() { + val removals = pendingExternalFileRemovals() + if (removals.isEmpty()) return + + val pendingBookIds = removals.mapTo(mutableSetOf()) { it.bookId } + if (prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) in pendingBookIds) { + clearPersistedReaderSession() + } + + viewModelScope.launch { + removals.forEach { removal -> + deletePendingExternalFileRemoval(removal) + } + } + } + + private fun deletePendingExternalFileRemoval(bookId: String, uriString: String?) { + markPendingExternalFileRemoval(bookId, uriString) + viewModelScope.launch { + deletePendingExternalFileRemoval(PendingExternalFileRemoval(bookId, uriString)) + } + } + + private suspend fun deletePendingExternalFileRemoval(removal: PendingExternalFileRemoval) { + var shouldRetry = false + runCatching { + cleanupBookDataLocally(removal.bookId) + }.onFailure { error -> + Timber.w(error, "Failed to clear local caches for pending external file ${removal.bookId}") + } + + runCatching { + recentFilesRepository.deleteFilePermanently(listOf(removal.bookId)) + }.onFailure { error -> + shouldRetry = true + Timber.w(error, "Failed to remove pending external file ${removal.bookId} from library") + } + + removal.uriString?.let { uriString -> + runCatching { + bookImporter.deleteBookByUriString(uriString) + }.onFailure { error -> + shouldRetry = true + Timber.w(error, "Failed to delete pending external file copy for ${removal.bookId}") + } + } + + if (!shouldRetry) { + clearPendingExternalFileRemovals(setOf(removal.bookId)) + } + } + + private fun encodePendingExternalFileRemoval(removal: PendingExternalFileRemoval): String { + return JSONObject() + .put("bookId", removal.bookId) + .apply { + if (!removal.uriString.isNullOrBlank()) { + put("uriString", removal.uriString) + } + } + .toString() + } + + private fun decodePendingExternalFileRemoval(value: String): PendingExternalFileRemoval? { + val trimmed = value.trim() + if (trimmed.isBlank()) return null + return if (trimmed.startsWith("{")) { + runCatching { + val json = JSONObject(trimmed) + val bookId = json.optString("bookId").takeIf { it.isNotBlank() } + val uriString = json.optString("uriString").takeIf { it.isNotBlank() } + bookId?.let { PendingExternalFileRemoval(it, uriString) } + }.getOrNull() + } else { + PendingExternalFileRemoval(trimmed, null) + } + } + private fun restoreReaderSessionIfNeeded() { val currentState = _internalState.value if (currentState.selectedBookId != null || currentState.selectedPdfUri != null || currentState.selectedEpubUri != null) { @@ -1286,6 +1428,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio runCatching { FileType.valueOf(typeName) }.getOrNull() } val restoreBookId = prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) ?: return + if (restoreBookId in pendingExternalFileRemovals().map { it.bookId }) { + clearPersistedReaderSession() + return + } if (persistedType == null) { clearPersistedReaderSession() return @@ -1614,17 +1760,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun importFont(uri: Uri) { + importFonts(listOf(uri)) + } + + fun importFonts(uris: List) { + if (uris.isEmpty()) return viewModelScope.launch { _internalState.update { it.copy(isLoading = true) } - val result = fontsRepository.importFont(uri) - result.onSuccess { font -> - if (uiState.value.isSyncEnabled) { - uploadNewFont(font) + try { + uris.forEach { uri -> + val result = fontsRepository.importFont(uri) + result.onSuccess { font -> + if (uiState.value.isSyncEnabled) { + uploadNewFont(font) + } + }.onFailure { + showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true) + } } - }.onFailure { - showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true) + } finally { + _internalState.update { it.copy(isLoading = false) } } - _internalState.update { it.copy(isLoading = false) } } } @@ -1650,9 +1806,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun deleteFont(fontId: String) { + deleteFonts(listOf(fontId)) + } + + fun deleteFonts(fontIds: Collection) { + val uniqueFontIds = fontIds.filter { it.isNotBlank() }.toSet() + if (uniqueFontIds.isEmpty()) return viewModelScope.launch { - fontsRepository.deleteFont(fontId) - if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) { + uniqueFontIds.forEach { fontId -> + fontsRepository.deleteFont(fontId) + } + if (uniqueFontIds.any { _internalState.value.appFontPreference.referencesCustomFont(it) }) { setAppFontPreference(AppFontPreference.System) } } @@ -2042,48 +2206,217 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun uploadSingleBookMetadata(book: RecentFileItem) { + private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) { if (!uiState.value.isSyncEnabled) return + cloudMetadataUploadJobs.remove(bookId)?.cancel() + val job = viewModelScope.launch { + if (debounce) delay(CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS) + val latest = recentFilesRepository.getFileByBookId(bookId) ?: run { + logCloudSyncTrace { "android.upload.queue_skip reason=missing_local book=$bookId trigger=$reason" } + return@launch + } + logCloudSyncTrace { + "android.upload.queue_fire trigger=$reason debounce=$debounce ${latest.cloudSyncTraceSummary()}" + } + uploadSingleBookMetadata(latest) + } + cloudMetadataUploadJobs[bookId] = job + job.invokeOnCompletion { + if (cloudMetadataUploadJobs[bookId] == job) { + cloudMetadataUploadJobs.remove(bookId) + } + } + } + + fun queuePdfSidecarCloudUpload(bookId: String) { + queueCloudMetadataUpload(bookId, reason = "pdf_sidecar") + } + + private fun uploadSingleBookMetadata(book: RecentFileItem) { + if (!uiState.value.isSyncEnabled) { + logCloudSyncTrace { "android.upload.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" } + return + } if (book.uriString?.startsWith("opds-pse") == true) { + logCloudSyncTrace { "android.upload.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}") return } if (book.sourceFolderUri != null) { + logCloudSyncTrace { "android.upload.skip reason=folder_book ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping metadata sync for local folder book: ${book.displayName}") return } if (book.isManualOnlyReaderFile()) { + logCloudSyncTrace { "android.upload.skip reason=manual_only ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}") return } - val currentUser = uiState.value.currentUser ?: return + val currentUser = uiState.value.currentUser ?: run { + logCloudSyncTrace { "android.upload.skip reason=no_user ${book.cloudSyncTraceSummary()}" } + return + } viewModelScope.launch { try { val deviceId = getInstallationId() + var bookForMetadata = book + var uploadedAnnotationPayload = false + var uploadedAnnotationModifiedTimestamp = 0L + var remoteBookLoaded = false + var remoteBookForUpload: BookMetadata? = null + var remoteAnnotationDriveTimestampLoaded = false + var remoteAnnotationDriveTimestamp = 0L + suspend fun loadRemoteBookForUpload(): BookMetadata? { + if (!remoteBookLoaded) { + remoteBookForUpload = firestoreRepository.getBookMetadata(currentUser.uid, book.bookId) + remoteBookLoaded = true + } + return remoteBookForUpload + } + suspend fun loadRemoteAnnotationDriveTimestamp(): Long { + if (!remoteAnnotationDriveTimestampLoaded) { + val remote = loadRemoteBookForUpload() + remoteAnnotationDriveTimestamp = if (remote?.hasAnnotations == true) { + googleDriveRepository.getAccessToken(appContext)?.let { accessToken -> + googleDriveRepository.getFiles(accessToken) + ?.files + .orEmpty() + .firstOrNull { it.name == cloudPdfAnnotationDriveFileName(book.bookId) } + ?.modifiedTimeMillis + } ?: 0L + } else { + 0L + } + remoteAnnotationDriveTimestampLoaded = true + } + return remoteAnnotationDriveTimestamp + } + if (book.needsRemoteEpubAnnotationMetadataGuard()) { + val remote = loadRemoteBookForUpload() + val merged = bookForMetadata.mergeRemoteEpubAnnotationMetadata(remote) + if (merged != bookForMetadata) { + logCloudSyncTrace { + "android.upload.epub_annotation_preserve book=${book.bookId} " + + "local=${bookForMetadata.cloudSyncTraceSummary()} " + + "remote=${remote?.cloudSyncTraceSummary() ?: "null"} " + + "merged=${merged.cloudSyncTraceSummary()}" + } + recentFilesRepository.addRecentFile(merged) + bookForMetadata = merged + } + } + val remoteForContent = loadRemoteBookForUpload()?.toRecentFileItem() + if (shouldUploadLocalBookContent(bookForMetadata, remoteForContent)) { + val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run { + logCloudSyncTrace { "android.upload.content_guard_skip reason=no_access_token ${bookForMetadata.cloudSyncTraceSummary()}" } + return@launch + } + val source = bookForMetadata.getUri()?.path?.let(::File) + if (source?.exists() != true) { + logCloudSyncTrace { + "android.upload.content_guard_skip reason=file_missing book=${bookForMetadata.bookId} " + + "path=${(source?.absolutePath).cloudSyncPreview()}" + } + return@launch + } + logCloudSyncTrace { + "android.upload.content_guard_start book=${bookForMetadata.bookId} " + + "localContentTs=${bookForMetadata.fileContentModifiedTimestamp} " + + "remoteContentTs=${remoteForContent?.fileContentModifiedTimestamp ?: 0L}" + } + val uploadedFile = googleDriveRepository.uploadFile( + accessToken, + bookForMetadata.bookId, + source, + bookForMetadata.type + ) + if (uploadedFile == null) { + logCloudSyncTrace { "android.upload.content_guard_failed book=${bookForMetadata.bookId}" } + return@launch + } + val contentTimestamp = bookForMetadata.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: source.lastModified() + bookForMetadata = bookForMetadata.copy( + fileSize = source.length(), + fileContentModifiedTimestamp = contentTimestamp + ) + logCloudSyncTrace { + "android.upload.content_guard_success book=${bookForMetadata.bookId} " + + "driveId=${uploadedFile.id} contentTs=$contentTimestamp" + } + } + logCloudSyncTrace { "android.upload.start device=$deviceId ${bookForMetadata.cloudSyncTraceSummary()}" } Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}") val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId) + val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(book.bookId) val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId) val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId) val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId) - val hasInk = inkFile?.exists() == true - val hasRichText = richTextFile.exists() + val hasInk = inkFile.hasSyncableCloudAnnotationPayload() + val hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload() + val hasRichText = richTextFile.hasSyncableCloudAnnotationPayload() val hasLayout = layoutFile.exists() - val hasTextBoxes = textBoxFile.exists() - val hasHighlights = highlightFile.exists() - val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights + val hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload() + val hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload() + val sidecars = AndroidPdfCloudSidecarState( + hasInk = hasInk, + inkTimestamp = inkFile?.lastModified() ?: 0L, + hasDeletedInk = hasDeletedInk, + deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L, + hasRichText = hasRichText, + richTextTimestamp = richTextFile.lastModified(), + hasLayout = hasLayout, + layoutTimestamp = layoutFile.lastModified(), + hasTextBoxes = hasTextBoxes, + textBoxesTimestamp = textBoxFile.lastModified(), + hasHighlights = hasHighlights, + highlightsTimestamp = highlightFile.lastModified() + ) + logCloudSyncTrace { + "android.upload.sidecars book=${book.bookId} hasInk=$hasInk hasDeletedInk=$hasDeletedInk hasText=$hasRichText " + + "hasLayout=$hasLayout hasTextBoxes=$hasTextBoxes hasHighlights=$hasHighlights " + + "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp}" + } + logCloudAnnotationSyncTrace { + "android.upload.inspect book=${book.bookId} remoteHas=${remoteBookForUpload?.hasAnnotations} " + + "remoteTs=${remoteBookForUpload?.lastModifiedTimestamp ?: 0L} " + + "ink{exists=$hasInk bytes=${inkFile?.length() ?: 0L} ts=${sidecars.inkTimestamp}} " + + "deletedInk{exists=$hasDeletedInk bytes=${deletedInkFile?.length() ?: 0L} ts=${sidecars.deletedInkTimestamp}} " + + "text{exists=$hasRichText bytes=${richTextFile.length()} ts=${sidecars.richTextTimestamp}} " + + "layout{exists=$hasLayout bytes=${layoutFile.length()} ts=${sidecars.layoutTimestamp}} " + + "textBoxes{exists=$hasTextBoxes bytes=${textBoxFile.length()} ts=${sidecars.textBoxesTimestamp}} " + + "highlights{exists=$hasHighlights bytes=${highlightFile.length()} ts=${sidecars.highlightsTimestamp}} " + + "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp} " + + "hasPayload=${sidecars.hasAnnotationPayload}" + } + val remoteAnnotationDriveTimestampForUpload = loadRemoteAnnotationDriveTimestamp() + val remoteAnnotationTimestampForUpload = + remoteBookForUpload?.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestampForUpload) ?: 0L + val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = sidecars, + remoteHasAnnotations = remoteBookForUpload?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestampForUpload + ) + logCloudAnnotationSyncTrace { + "android.upload.annotation_decision book=${book.bookId} " + + "localShouldUpload=$localAnnotationsShouldUpload remoteHas=${remoteBookForUpload?.hasAnnotations} " + + "remoteAnnTs=$remoteAnnotationTimestampForUpload " + + "remoteDriveAnnTs=$remoteAnnotationDriveTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}" + } Timber.d( "android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " + - "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData" + "richBytes=${if (hasRichText) richTextFile.length() else 0L} " + + "hasAnnotationPayload=${sidecars.hasAnnotationPayload}" ) - if (hasAnyData) { + if (localAnnotationsShouldUpload) { if (googleDriveRepository.hasDrivePermissions(appContext)) { val accessToken = googleDriveRepository.getAccessToken(appContext) @@ -2115,6 +2448,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } if (hasInk) putJsonSafe("ink", inkFile) + if (hasDeletedInk) putJsonSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile) if (hasRichText) putJsonSafe("text", richTextFile) if (hasLayout) putJsonSafe("layout", layoutFile) if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile) @@ -2123,7 +2457,54 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val bundleFile = File(appContext.cacheDir, "sync_bundle_${book.bookId}.json") val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) - bundleFile.writeText(canonicalBundle) + var uploadBundle = canonicalBundle + var mergedRemoteIntoUpload = false + if (remoteBookForUpload?.hasAnnotations == true) { + val remoteBundleFile = File(appContext.cacheDir, "remote_sync_bundle_${book.bookId}.json") + try { + val didDownloadRemote = googleDriveRepository.downloadAnnotationFile( + accessToken, + book.bookId, + remoteBundleFile + ) + if (didDownloadRemote && remoteBundleFile.isFile) { + val remoteBundle = remoteBundleFile.readText() + val mergedBundle = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson( + localDataJson = canonicalBundle, + remoteDataJson = remoteBundle, + preferRemoteOnConflict = false + ) + val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(canonicalBundle) + val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteBundle) + val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedBundle) + uploadBundle = mergedBundle + mergedRemoteIntoUpload = mergedBundle != canonicalBundle + logCloudAnnotationSyncTrace { + "android.upload.merge_remote book=${book.bookId} didDownload=true " + + "localCount=$localCount remoteCount=$remoteCount mergedCount=$mergedCount " + + "changed=$mergedRemoteIntoUpload" + } + } else { + logCloudAnnotationSyncTrace { + "android.upload.merge_remote_missing book=${book.bookId} " + + "didDownload=$didDownloadRemote tempExists=${remoteBundleFile.exists()}" + } + } + } catch (e: Exception) { + logCloudAnnotationSyncError(e) { + "android.upload.merge_remote_failed book=${book.bookId}" + } + } finally { + remoteBundleFile.delete() + } + } + bundleFile.writeText(uploadBundle) + logCloudAnnotationSyncTrace { + "android.upload.bundle_ready book=${book.bookId} " + + "rawKeys=${bundleJson.keys().asSequence().toList()} " + + "canonicalBytes=${canonicalBundle.length} uploadBytes=${uploadBundle.length} " + + "fileBytes=${bundleFile.length()}" + } if (hasRichText) { Timber.d( "android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " + @@ -2137,6 +2518,36 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio bundleFile.delete() if (uploaded != null) { + uploadedAnnotationPayload = true + uploadedAnnotationModifiedTimestamp = uploaded.modifiedTimeMillis + if (mergedRemoteIntoUpload) { + recentFilesRepository.importAnnotationBundle( + book.bookId, + uploadBundle, + uploadedAnnotationModifiedTimestamp + ) + logCloudAnnotationSyncTrace { + "android.upload.local_apply_merged book=${book.bookId} " + + "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}" + } + } + markPdfCloudAnnotationSidecarsSynced( + uploadedAnnotationModifiedTimestamp, + inkFile, + richTextFile, + layoutFile, + textBoxFile, + highlightFile, + deletedInkFile + ) + logCloudAnnotationSyncTrace { + "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " + + "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}" + } + logCloudSyncTrace { + "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " + + "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}" + } if (hasRichText) { Timber .d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}") @@ -2144,6 +2555,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("AnnotationSync") .d("Bundle upload SUCCESS. ID: ${uploaded.id}") } else { + logCloudAnnotationSyncTrace { + "android.upload.sidecar_failed book=${book.bookId} bytes=${uploadBundle.length}" + } + logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; aborting_metadata_upload" } if (hasRichText) { Timber .e("android.cloud.export.uploadFailed book=${book.bookId}") @@ -2152,23 +2567,127 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio .e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") return@launch } + } else { + logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=no_access_token book=${book.bookId}" } + logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=no_access_token; aborting_metadata_upload" } + return@launch } + } else { + logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=missing_drive_permission book=${book.bookId}" } + logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=missing_drive_permission; aborting_metadata_upload" } + return@launch } } else { - Timber.tag("AnnotationSync") - .d("No local data (ink/text/layout) to upload for ${book.bookId}") + logCloudAnnotationSyncTrace { + "android.upload.skip_sidecar reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"} " + + "book=${book.bookId} layoutOnly=$hasLayout layoutTs=${sidecars.layoutTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}" + } + logCloudSyncTrace { + "android.upload.sidecars_skipped book=${book.bookId} " + + "reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"}" + } + Timber.tag("AnnotationSync").d( + if (sidecars.hasAnnotationPayload) { + "Local annotation payload is not newer than remote for ${book.bookId}" + } else { + "No local annotation payload (ink/text/text boxes/highlights) to upload for ${book.bookId}" + } + ) + } + + val latestLocalForMetadata = recentFilesRepository.getFileByBookId(bookForMetadata.bookId) + val refreshedBookForMetadata = bookForMetadata.withFreshLocalReadingPositionForCloudUpload( + latestLocalForMetadata + ) + if (refreshedBookForMetadata != bookForMetadata) { + logCloudSyncTrace { + "android.upload.refresh_latest book=${bookForMetadata.bookId} " + + "before=${bookForMetadata.cloudSyncTraceSummary()} " + + "latest=${latestLocalForMetadata?.cloudSyncTraceSummary() ?: "null"} " + + "after=${refreshedBookForMetadata.cloudSyncTraceSummary()}" + } + bookForMetadata = refreshedBookForMetadata + } + val remoteMetadata = loadRemoteBookForUpload() + val localReadingTimestamp = bookForMetadata.effectiveReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remoteMetadata?.effectiveReadingPositionModifiedTimestamp() ?: 0L + val remoteAnnotationTimestamp = remoteMetadata?.effectiveAnnotationModifiedTimestamp( + remoteAnnotationDriveTimestampForUpload + ) ?: 0L + val remoteReadingPositionWins = remoteMetadata != null && remoteReadingTimestamp > localReadingTimestamp + val remoteMetadataWins = remoteMetadata != null && + remoteMetadata.lastModifiedTimestamp > bookForMetadata.lastModifiedTimestamp + val metadataBase = if (remoteMetadataWins && remoteMetadata != null) { + remoteMetadata.toRecentFileItem().withLocalStorageForCloudMetadata(bookForMetadata) + } else { + bookForMetadata + } + val metadataBook = when { + remoteReadingPositionWins && remoteMetadata != null -> metadataBase.withCloudReadingPosition(remoteMetadata) + metadataBase != bookForMetadata -> metadataBase.withLocalReadingPosition(bookForMetadata) + else -> bookForMetadata + } + val readingPositionTimestamp = if (remoteReadingPositionWins) { + remoteReadingTimestamp + } else { + localReadingTimestamp + } + if (remoteReadingPositionWins) { + logCloudSyncTrace { + "android.upload.preserve_remote_position book=${bookForMetadata.bookId} " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "remote=${remoteMetadata?.cloudSyncTraceSummary() ?: "null"}" + } + } + if (remoteMetadataWins) { + logCloudSyncTrace { + "android.upload.preserve_remote_metadata book=${bookForMetadata.bookId} " + + "remoteTs=${remoteMetadata?.lastModifiedTimestamp ?: 0L} localTs=${bookForMetadata.lastModifiedTimestamp} " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + metadataBook.cloudSyncTraceSummary("metadata") + } } val newTimestamp = System.currentTimeMillis() - val metadataToSync = book.toBookMetadata().copy( - lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData + val syncedAnnotationTimestamp = if (uploadedAnnotationPayload) { + uploadedAnnotationModifiedTimestamp.takeIf { it > 0L } + ?: maxOf(sidecars.annotationPayloadTimestamp, newTimestamp) + } else if (remoteMetadata?.hasAnnotations == true) { + remoteAnnotationTimestamp + } else { + 0L + } + val syncedHasAnnotations = uploadedAnnotationPayload || remoteMetadata?.hasAnnotations == true + val metadataToSync = metadataBook.toBookMetadata().copy( + lastModifiedTimestamp = newTimestamp, + readingPositionModifiedTimestamp = readingPositionTimestamp, + annotationModifiedTimestamp = syncedAnnotationTimestamp, + hasAnnotations = syncedHasAnnotations ) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) - recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp)) + recentFilesRepository.addRecentFile( + metadataBook.copy( + lastModifiedTimestamp = newTimestamp, + readingPositionModifiedTimestamp = readingPositionTimestamp + ) + ) + logCloudAnnotationSyncTrace { + "android.upload.metadata_success book=${bookForMetadata.bookId} newTs=$newTimestamp " + + "readTs=$readingPositionTimestamp hasAnnotations=$syncedHasAnnotations " + + "annTs=$syncedAnnotationTimestamp payloadTs=${sidecars.annotationPayloadTimestamp}" + } + logCloudSyncTrace { + "android.upload.metadata_success user=${currentUser.uid} oldTs=${bookForMetadata.lastModifiedTimestamp} " + + "newTs=$newTimestamp readTs=$readingPositionTimestamp annTs=$syncedAnnotationTimestamp " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "hasAnnotations=$syncedHasAnnotations ${metadataBook.cloudSyncTraceSummary()}" + } Timber.tag("AnnotationSync") - .d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)") + .d("Firestore metadata updated for ${book.bookId} (hasAnnotationPayload=${sidecars.hasAnnotationPayload}, syncedHasAnnotations=$syncedHasAnnotations)") } catch (e: Exception) { + logCloudSyncError(e) { "android.upload.failed ${book.cloudSyncTraceSummary()}" } Timber.tag("AnnotationSync").e(e, "Failed to sync book data: ${book.bookId}") } } @@ -2242,6 +2761,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val closingBookId = _internalState.value.selectedBookId val uriString = _internalState.value.selectedPdfUri?.toString() ?: _internalState.value.selectedEpubUri?.toString() + logCloudSyncTrace { + "android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}" + } val ttsState = ttsController.ttsState.value val isTtsActive = ttsState.playbackSource == "READER" && @@ -2274,26 +2796,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } clearPersistedReaderSession() + var removesExternalFileOnClose = false if (closingBookId != null && closingBookId == externalOpenedBookId) { val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" if (behavior == "ASK") { _internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) } } else if (behavior == "DELETE") { - deleteBookPermanently(closingBookId) + removesExternalFileOnClose = true + deletePendingExternalFileRemoval(closingBookId, uriString) + } else { + clearPendingExternalFileRemovals(setOf(closingBookId)) } externalOpenedBookId = null } - if (uriString != null) { + if (uriString != null && !removesExternalFileOnClose) { viewModelScope.launch { val freshBook = recentFilesRepository.getFileByUri(uriString) freshBook?.let { if (uiState.value.uploadingBookIds.contains(it.bookId)) { + logCloudSyncTrace { "android.reader.close_upload_skip reason=already_uploading ${it.cloudSyncTraceSummary()}" } return@launch } if (uiState.value.isSyncEnabled) { + logCloudSyncTrace { "android.reader.close_upload_start ${it.cloudSyncTraceSummary()}" } Timber.d("Book closed, triggering metadata sync for ${it.bookId}") uploadSingleBookMetadata(it) + } else { + logCloudSyncTrace { "android.reader.close_upload_skip reason=sync_disabled ${it.cloudSyncTraceSummary()}" } } if (it.sourceFolderUri != null) { @@ -2393,72 +2923,32 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun loadSyncedFoldersFromPrefs(): List { - val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null) - val folders = mutableListOf() + val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null) + val oldUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null) + val folders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = jsonString, + legacyUri = oldUri, + legacyLastScanTime = prefs.getLong(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME, 0L), + legacyNameResolver = { uri -> getDisplayPathFromUri(appContext, uri) } + ) - if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) { - val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null) - val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L) - if (oldUri != null) { - val name = getDisplayPathFromUri(appContext, oldUri) - val migrated = SyncedFolder(oldUri, name, oldTime, ANDROID_SYNCABLE_FILE_TYPES) - folders.add(migrated) - saveSyncedFoldersToPrefs(folders) - - prefs.edit { - remove(KEY_SYNCED_FOLDER_URI) - remove(KEY_LAST_FOLDER_SCAN_TIME) - } - } - } else if (jsonString != null) { - try { - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - - val allowedFileTypes = mutableSetOf() - if (obj.has("allowedFileTypes")) { - val typesArray = obj.getJSONArray("allowedFileTypes") - for (j in 0 until typesArray.length()) { - try { - allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) - } catch (_: Exception) {} - } - } else { - allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES) - } - - folders.add( - SyncedFolder( - uriString = obj.getString("uri"), - name = obj.getString("name"), - lastScanTime = obj.optLong("lastScanTime", 0L), - allowedFileTypes = allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES } - ) - ) - } - } catch (e: Exception) { - Timber.e(e, "Failed to parse synced folders JSON") + if (jsonString == null && prefs.contains(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI) && oldUri != null) { + saveSyncedFoldersToPrefs(folders) + prefs.edit { + remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI) + remove(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME) } } return folders } private fun saveSyncedFoldersToPrefs(folders: List) { - val jsonArray = JSONArray() - folders.forEach { folder -> - val obj = JSONObject() - obj.put("uri", folder.uriString) - obj.put("name", folder.name) - obj.put("lastScanTime", folder.lastScanTime) - val typesArray = JSONArray() - folder.allowedFileTypes - .filter { it in ANDROID_SYNCABLE_FILE_TYPES } - .forEach { typesArray.put(it.name) } - obj.put("allowedFileTypes", typesArray) - jsonArray.put(obj) + prefs.edit { + putString( + SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, + SyncedFolderPrefs.encodeSyncedFolders(folders) + ) } - prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) } } fun addSyncedFolder(folderUri: Uri) { @@ -2482,7 +2972,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) val name = getDisplayPathFromUri(appContext, folderUri.toString()) - val newFolder = SyncedFolder(folderUri.toString(), name, 0L, ANDROID_SYNCABLE_FILE_TYPES) + val newFolder = SyncedFolder( + uriString = folderUri.toString(), + name = name, + lastScanTime = 0L, + allowedFileTypes = ANDROID_SYNCABLE_FILE_TYPES, + localSyncEnabled = true + ) val newStats = currentFolders + newFolder saveSyncedFoldersToPrefs(newStats) @@ -2541,6 +3037,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun setFolderLocalSyncEnabled( + folder: SyncedFolder, + enabled: Boolean, + removeSyncDataFolder: Boolean = false + ) { + viewModelScope.launch { + val currentFolders = _internalState.value.syncedFolders.toMutableList() + val index = currentFolders.indexOfFirst { it.uriString == folder.uriString } + if (index == -1) return@launch + + val updatedFolder = currentFolders[index].copy(localSyncEnabled = enabled) + currentFolders[index] = updatedFolder + saveSyncedFoldersToPrefs(currentFolders) + _internalState.update { it.copy(syncedFolders = currentFolders) } + + if (enabled) { + showBanner(appContext.getString(R.string.banner_folder_local_sync_enabled)) + triggerFolderSyncWorker( + metadataOnly = false, + showFeedback = true, + targetFolderUriString = updatedFolder.uriString + ) + } else { + val workManager = WorkManager.getInstance(appContext) + workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) + workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) + + if (removeSyncDataFolder) { + val removed = withContext(Dispatchers.IO) { + LocalSyncUtils.deleteSyncDataFolder(appContext, updatedFolder.uriString.toUri()) + } + val message = if (removed) { + appContext.getString(R.string.banner_folder_local_sync_disabled_removed_data) + } else { + appContext.getString(R.string.banner_folder_sync_data_remove_failed) + } + showBanner(message, isError = !removed) + } else { + showBanner(appContext.getString(R.string.banner_folder_local_sync_disabled)) + } + } + } + } + fun syncFolderMetadata(showFeedback: Boolean = false) { triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback) } @@ -2554,11 +3094,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio showFeedback: Boolean, targetFolderUriString: String? = null ) { - val folders = _internalState.value.syncedFolders - if (folders.isEmpty()) return + val allFolders = _internalState.value.syncedFolders + val folders = if (targetFolderUriString.isNullOrBlank()) { + allFolders.filter { it.localSyncEnabled } + } else { + allFolders.filter { it.uriString == targetFolderUriString && it.localSyncEnabled } + } + if (folders.isEmpty()) { + if (showFeedback) { + showBanner(appContext.getString(R.string.error_no_enabled_folder_sync), isError = true) + } + return + } val targetFolderName = targetFolderUriString - ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target } + ?.let { target -> allFolders.firstOrNull { it.uriString == target }?.name ?: target } ReaderPerfLog.d( "FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " + "metadataOnly=$metadataOnly feedback=$showFeedback" @@ -2690,8 +3240,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } prefs.edit { - remove(KEY_SYNCED_FOLDERS_JSON) - remove(KEY_SYNCED_FOLDER_URI) + remove(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON) + remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI) } _internalState.update { it.copy(syncedFolders = emptyList()) } } @@ -2718,8 +3268,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio it.name } - val fileExtension = item.type.name.lowercase() - val fileName = "${item.bookId}.$fileExtension" + val fileName = sharedCloudBookContentFileName(item.bookId, item.type) + ?: throw Exception("Unsupported cloud file type: ${item.type}") val driveFileId = remoteFiles[fileName]?.id if (driveFileId != null) { @@ -2729,6 +3279,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio accessToken, driveFileId, destinationFile ) ) { + if (item.fileContentModifiedTimestamp > 0L) { + destinationFile.setLastModified(item.fileContentModifiedTimestamp) + } addFileToRecent( destinationFile.toUri(), item.type, @@ -2987,25 +3540,38 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun shouldDownloadRemoteBookContent(local: RecentFileItem, remote: RecentFileItem): Boolean { + val localFile = local.getUri()?.path?.let(::File) + val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L return local.sourceFolderUri == null && !local.isDeleted && - local.type == FileType.EPUB && - remote.type == FileType.EPUB && - !remote.isDeleted && - remote.fileContentModifiedTimestamp > 0L && - remote.fileContentModifiedTimestamp > local.fileContentModifiedTimestamp + local.type == remote.type && + sharedCloudBookContentFileName(local.bookId, local.type) != null && + shouldDownloadRemoteCloudBookContent( + localFileAvailable = local.isAvailable && localFile?.isFile != false, + localContentModifiedTimestamp = localContentTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp, + remoteDeleted = remote.isDeleted + ) } private fun shouldUploadLocalBookContent(local: RecentFileItem, remote: RecentFileItem?): Boolean { + val localFile = local.getUri()?.path?.let(::File) + val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L return local.sourceFolderUri == null && - local.type == FileType.EPUB && - local.fileContentModifiedTimestamp > 0L && - local.fileContentModifiedTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L) + sharedCloudBookContentFileName(local.bookId, local.type) != null && + shouldUploadLocalCloudBookContent( + localFileAvailable = local.isAvailable && localFile?.isFile == true, + localContentModifiedTimestamp = localContentTimestamp, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + ) } private suspend fun downloadCloudBookFile(accessToken: String, remote: RecentFileItem): Boolean { - val fileExtension = remote.type.name.lowercase() - val fileName = "${remote.bookId}.$fileExtension" + val fileName = sharedCloudBookContentFileName(remote.bookId, remote.type) ?: return false val driveFileId = googleDriveRepository.getFiles(accessToken) ?.files .orEmpty() @@ -3034,6 +3600,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return true } + private fun scheduleCloudContentRetry(bookIds: Set) { + if (bookIds.isEmpty() || cloudContentRetryJob?.isActive == true) return + cloudContentRetryJob = viewModelScope.launch { + delay(CLOUD_CONTENT_RETRY_DELAY_MILLIS) + if (uiState.value.isSyncEnabled) { + logCloudSyncTrace { "android.full_sync.content_retry books=${bookIds.joinToString()}" } + syncWithCloud(showBanner = false).join() + } + } + } + fun setFolderSyncEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) } _internalState.update { it.copy(isFolderSyncEnabled = enabled) } @@ -3048,12 +3625,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val currentUser = _internalState.value.currentUser if (!hasPermissions || currentUser == null) { + logCloudSyncTrace { + "android.full_sync.skip reason=${if (!hasPermissions) "missing_drive_permissions" else "no_user"} " + + "showBanner=$showBanner" + } if (showBanner) _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_sync)) } return@launch } + logCloudSyncTrace { + "android.full_sync.start user=${currentUser.uid} showBanner=$showBanner " + + "folderSync=${_internalState.value.isFolderSyncEnabled}" + } if (showBanner) { _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_checking))) @@ -3061,7 +3646,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } try { - val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch + val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run { + logCloudSyncTrace { "android.full_sync.skip reason=no_access_token user=${currentUser.uid}" } + return@launch + } val deviceId = getInstallationId() val remoteBooksDeferred = async(Dispatchers.IO) { @@ -3086,6 +3674,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val remoteBooks = remoteBooksDeferred.await() .filterNot { it.isManualOnlyReaderFile() } val remoteShelves = remoteShelvesDeferred.await() + val initialDriveFiles = withContext(Dispatchers.IO) { + googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } + } + logCloudSyncTrace { + "android.full_sync.loaded user=${currentUser.uid} device=$deviceId " + + "localBooks=${localBooks.size} remoteBooks=${remoteBooks.size} " + + "remoteShelves=${remoteShelves.size} driveFiles=${initialDriveFiles.size}" + } val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet() val allKnownShelfNames = (localShelfNames + remoteShelves.map { it.name }).toSet() @@ -3103,17 +3699,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val localBooksMap = localBooks.associateBy { it.bookId } val remoteBooksMap = remoteBooks.associateBy { it.bookId } val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() + val pendingContentDownloads = mutableSetOf() allBookIds.forEach { bookId -> val local = localBooksMap[bookId] val remote = remoteBooksMap[bookId] if (local?.sourceFolderUri != null) { + logCloudSyncTrace { "android.full_sync.book_skip reason=folder_book ${local.cloudSyncTraceSummary()}" } Timber.d("Skipping cloud book metadata merge for local folder book: ${local.displayName}") return@forEach } if (local != null && remote != null) { + logCloudSyncTrace { + "android.full_sync.compare book=$bookId ${local.cloudSyncTraceSummary()} " + + "${remote.cloudSyncTraceSummary()} " + } Timber.tag("AnnotationSync").d( "Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}" ) @@ -3122,42 +3724,182 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when { local != null && remote == null -> { if (local.isDeleted) { + logCloudSyncTrace { "android.full_sync.decision action=upload_deleted_metadata ${local.cloudSyncTraceSummary()}" } uploadSingleBookMetadata(local) } else { + logCloudSyncTrace { "android.full_sync.decision action=upload_new_book ${local.cloudSyncTraceSummary()}" } uploadNewBookAndMetadata(local) } } local == null && remote != null -> { + if (remote.isDeleted) { + logCloudSyncTrace { "android.full_sync.decision action=skip_deleted_remote_only ${remote.cloudSyncTraceSummary()}" } + return@forEach + } + logCloudSyncTrace { "android.full_sync.decision action=apply_remote_new ${remote.cloudSyncTraceSummary()}" } recentFilesRepository.addRecentFile(remote.toRecentFileItem()) if (remote.hasAnnotations) { - downloadAnnotationsForBook(accessToken, bookId) + val remoteAnnotationDriveTimestamp = + initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L + val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) + logCloudAnnotationSyncTrace { + "android.full_sync.remote_only_download book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "remoteHasAnnotations=${remote.hasAnnotations}" + } + downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp) } } local != null && remote != null -> { val remoteItem = remote.toRecentFileItem() - val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remoteItem) + val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) + val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(bookId) + val richTextFile = pdfRichTextRepository.getFileForSync(bookId) + val layoutFile = pageLayoutRepository.getLayoutFile(bookId) + val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) + val highlightFile = pdfHighlightRepository.getFileForSync(bookId) + val localSidecars = AndroidPdfCloudSidecarState( + hasInk = inkFile.hasSyncableCloudAnnotationPayload(), + inkTimestamp = inkFile?.lastModified() ?: 0L, + hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload(), + deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L, + hasRichText = richTextFile.hasSyncableCloudAnnotationPayload(), + richTextTimestamp = richTextFile.lastModified(), + hasLayout = layoutFile.exists(), + layoutTimestamp = layoutFile.lastModified(), + hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload(), + textBoxesTimestamp = textBoxFile.lastModified(), + hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload(), + highlightsTimestamp = highlightFile.lastModified() + ) + val fileLastModified = localSidecars.annotationPayloadTimestamp + val remoteAnnotationDriveTimestamp = + initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L + val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) + val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = localSidecars, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp + ) + logCloudAnnotationSyncTrace { + "android.full_sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localTs=${local.lastModifiedTimestamp} " + + "remoteReadTs=${remote.effectiveReadingPositionModifiedTimestamp()} " + + "localReadTs=${local.effectiveReadingPositionModifiedTimestamp()} " + + "localPayload=${localSidecars.hasAnnotationPayload} " + + "localPayloadTs=${localSidecars.annotationPayloadTimestamp} " + + "layoutExists=${localSidecars.hasLayout} layoutTs=${localSidecars.layoutTimestamp} " + + "shouldUploadLocal=$localAnnotationsShouldUpload" + } + if (remote.isDeleted) { + val localWinsDeletedRemote = shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp = local.lastModifiedTimestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + val remoteDeleteWins = shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp = local.lastModifiedTimestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + when { + localWinsDeletedRemote -> { + logCloudSyncTrace { + "android.full_sync.decision action=resurrect_upload_local book=$bookId " + + "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified" + } + if (shouldUploadLocalBookContent(local, null)) { + uploadNewBookAndMetadata(local) + } else { + uploadSingleBookMetadata(local) + } + } + + remoteDeleteWins -> { + logCloudSyncTrace { + "android.full_sync.decision action=apply_remote_delete book=$bookId " + + "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified" + } + recentFilesRepository.deleteFilePermanently(listOf(bookId)) + } + + else -> { + logCloudSyncTrace { + "android.full_sync.decision action=skip_equal_delete book=$bookId " + + "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified" + } + } + } + return@forEach + } + val localWithRemoteEpubAnnotations = local.mergeRemoteEpubAnnotationMetadata(remote) + val effectiveLocal = if (localWithRemoteEpubAnnotations != local) { + logCloudSyncTrace { + "android.full_sync.decision action=merge_remote_epub_annotations book=$bookId " + + "local=${local.cloudSyncTraceSummary()} ${remote.cloudSyncTraceSummary()} " + + "merged=${localWithRemoteEpubAnnotations.cloudSyncTraceSummary()}" + } + recentFilesRepository.addRecentFile(localWithRemoteEpubAnnotations) + localWithRemoteEpubAnnotations + } else { + local + } + val localReadingTimestamp = effectiveLocal.effectiveReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remote.effectiveReadingPositionModifiedTimestamp() + val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp + val shouldDownloadContent = shouldDownloadRemoteBookContent(effectiveLocal, remoteItem) val downloadedRemoteContent = if (shouldDownloadContent) { - downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { local.displayName })) + logCloudSyncTrace { + "android.full_sync.content_download_start book=$bookId " + + "localContentTs=${effectiveLocal.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}" + } + downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { effectiveLocal.displayName })) } else { false } + logCloudSyncTrace { + "android.full_sync.content_decision book=$bookId shouldDownload=$shouldDownloadContent " + + "downloaded=$downloadedRemoteContent localPayloadSidecarTs=$fileLastModified " + + "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}" + } + if (shouldDownloadContent && !downloadedRemoteContent) { + pendingContentDownloads += bookId + } - if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) { - if (shouldUploadLocalBookContent(local, remoteItem)) { - uploadNewBookAndMetadata(local) + if (shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + ) { + logCloudSyncTrace { + "android.full_sync.decision action=upload_local book=$bookId " + + "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified " + + "uploadContent=${shouldUploadLocalBookContent(effectiveLocal, remoteItem)}" + } + if (shouldUploadLocalBookContent(effectiveLocal, remoteItem)) { + uploadNewBookAndMetadata(effectiveLocal) } else { - uploadSingleBookMetadata(local) + uploadSingleBookMetadata(effectiveLocal) } } else { val isMetadataNewer = - remote.lastModifiedTimestamp > local.lastModifiedTimestamp + shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) if (isMetadataNewer) { + logCloudSyncTrace { + "android.full_sync.decision action=apply_remote_metadata book=$bookId " + + "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " + + "downloadedContent=$downloadedRemoteContent" + } val remoteForLocalDb = if (shouldDownloadContent && !downloadedRemoteContent) { remote.toRecentFileItem().copy( - fileContentModifiedTimestamp = local.fileContentModifiedTimestamp + fileContentModifiedTimestamp = effectiveLocal.fileContentModifiedTimestamp ) } else { remote.toRecentFileItem() @@ -3165,31 +3907,76 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio recentFilesRepository.addRecentFile( remoteForLocalDb ) + if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) { + recentFilesRepository.getFileByBookId(bookId)?.let { merged -> + logCloudAnnotationSyncTrace { + "android.full_sync.upload_local_annotations book=$bookId reason=remote_metadata_newer " + + "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp " + + "uploadReadingPosition=$localReadingPositionShouldUpload" + } + logCloudSyncTrace { + "android.full_sync.decision action=upload_local_supplement book=$bookId " + + "remoteMetadataTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload" + } + uploadSingleBookMetadata(merged) + } + } + } else { + logCloudSyncTrace { + "android.full_sync.decision action=metadata_noop book=$bookId " + + "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified" + } + if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) { + logCloudAnnotationSyncTrace { + "android.full_sync.upload_local_annotations book=$bookId reason=metadata_noop " + + "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp" + } + logCloudSyncTrace { + "android.full_sync.decision action=upload_local_supplement book=$bookId " + + "metadataEqual=${effectiveLocal.lastModifiedTimestamp == remote.lastModifiedTimestamp} " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload " + + "payloadSidecarTs=$fileLastModified" + } + uploadSingleBookMetadata(effectiveLocal) + } } - val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) - val richTextFile = pdfRichTextRepository.getFileForSync(bookId) - val layoutFile = pageLayoutRepository.getLayoutFile(bookId) - val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) - val highlightFile = pdfHighlightRepository.getFileForSync(bookId) - - val anyLocalFileExists = - (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists() - val localFileMissing = !anyLocalFileExists - - val fileLastModified = maxOf( - inkFile?.lastModified() ?: 0L, - richTextFile.lastModified(), - layoutFile.lastModified(), - textBoxFile.lastModified(), - highlightFile.lastModified() + val shouldDownloadRemoteAnnotations = shouldDownloadRemotePdfCloudAnnotations( + localSidecars = localSidecars, + localAnnotationsShouldUpload = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp ) - val isFileStale = - remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) - if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) { + if (shouldDownloadRemoteAnnotations) { + logCloudAnnotationSyncTrace { + "android.full_sync.download_remote_annotations book=$bookId " + + "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "localPayloadTs=$fileLastModified " + + "layoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}" + } + logCloudSyncTrace { + "android.full_sync.sidecar_download_start book=$bookId reason=" + + "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "localPayloadSidecarTs=$fileLastModified " + + "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}" + } Timber.tag("AnnotationSync").d("Triggering download for $bookId.") - downloadAnnotationsForBook(accessToken, bookId) + downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp) + } else { + logCloudAnnotationSyncTrace { + "android.full_sync.skip_remote_annotations book=$bookId " + + "remoteHas=${remote.hasAnnotations} localShouldUpload=$localAnnotationsShouldUpload " + + "metadataNewer=$isMetadataNewer localPayload=${localSidecars.hasAnnotationPayload} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "localPayloadTs=$fileLastModified" + } } } } @@ -3274,41 +4061,80 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } } - val downloadJobs = mutableListOf() - finalMergedBooks.forEach { book -> if (book.sourceFolderUri != null) return@forEach - val fileExtension = book.type.name.lowercase() - val fileName = "${book.bookId}.$fileExtension" + val fileName = sharedCloudBookContentFileName(book.bookId, book.type) ?: return@forEach if (book.isDeleted) { remoteFiles[fileName]?.id?.let { fileId -> Timber.d("Deleting from Drive: $fileName") googleDriveRepository.deleteDriveFile(accessToken, fileId) } + remoteFiles[cloudPdfAnnotationDriveFileName(book.bookId)]?.id?.let { fileId -> + Timber.d("Deleting annotation bundle from Drive: ${book.bookId}") + googleDriveRepository.deleteDriveFile(accessToken, fileId) + } recentFilesRepository.deleteFilePermanently(listOf(book.bookId)) } else if ( book.sourceFolderUri == null && book.isAvailable && !remoteFiles.containsKey(fileName) ) { - book.getUri()?.path?.let { path -> - val file = File(path) - if (file.exists()) { - Timber.d("Uploading book: ${book.displayName}") - googleDriveRepository.uploadFile( - accessToken, book.bookId, file, book.type - ) + val remoteItem = remoteBooksMap[book.bookId]?.toRecentFileItem() + if (remoteItem == null || shouldUploadLocalBookContent(book, remoteItem)) { + book.getUri()?.path?.let { path -> + val file = File(path) + if (file.exists()) { + Timber.d("Uploading book: ${book.displayName}") + val uploadedFile = googleDriveRepository.uploadFile( + accessToken, book.bookId, file, book.type + ) + if (uploadedFile != null) { + val contentTimestamp = book.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: file.lastModified() + uploadSingleBookMetadata( + book.copy( + fileSize = file.length(), + fileContentModifiedTimestamp = contentTimestamp + ) + ) + } + } + } + } else { + pendingContentDownloads += book.bookId + logCloudSyncTrace { + "android.full_sync.content_wait_missing_remote book=${book.bookId} " + + "file=$fileName localContentTs=${book.fileContentModifiedTimestamp} " + + "remoteContentTs=${remoteItem.fileContentModifiedTimestamp}" } } } else if (!book.isAvailable && remoteFiles.containsKey(fileName)) { Timber.d("Sync: Triggering auto-download for ${book.displayName}") - downloadJobs.add(downloadBook(book)) + val remoteItem = remoteBooksMap[book.bookId] + ?.toRecentFileItem() + ?.copy(displayName = book.displayName) + ?: book + val downloaded = downloadCloudBookFile(accessToken, remoteItem) + if (!downloaded) { + pendingContentDownloads += book.bookId + } + } else if (!book.isAvailable) { + pendingContentDownloads += book.bookId } } - downloadJobs.joinAll() + if (pendingContentDownloads.isNotEmpty()) { + logCloudSyncTrace { + "android.full_sync.content_pending books=${pendingContentDownloads.joinToString()}" + } + scheduleCloudContentRetry(pendingContentDownloads) + } else { + cloudContentRetryJob?.cancel() + cloudContentRetryJob = null + } syncFonts(currentUser.uid) + logCloudSyncTrace { "android.full_sync.complete user=${currentUser.uid}" } if (showBanner) { _internalState.update { it.copy( @@ -3317,6 +4143,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } } catch (e: Exception) { + logCloudSyncError(e) { "android.full_sync.failed user=${currentUser.uid}" } Timber.tag("AnnotationSync").e(e, "Error during cloud sync") if (showBanner) { _internalState.update { @@ -3326,21 +4153,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private suspend fun downloadAnnotationsForBook(accessToken: String, bookId: String) { + private suspend fun downloadAnnotationsForBook( + accessToken: String, + bookId: String, + annotationModifiedTimestamp: Long + ) { // We download to a temp location first to inspect the content val tempDownloadFile = File(appContext.cacheDir, "temp_download_${bookId}.json") + logCloudSyncTrace { + "android.sidecar_download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}" + } + logCloudAnnotationSyncTrace { + "android.download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}" + } Timber.tag("AnnotationSync").d("Attempting download of bundle for $bookId.") val didDownload = googleDriveRepository.downloadAnnotationFile(accessToken, bookId, tempDownloadFile) if (didDownload && tempDownloadFile.exists()) { + logCloudAnnotationSyncTrace { + "android.download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}" + } + logCloudSyncTrace { + "android.sidecar_download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}" + } Timber.tag("AnnotationSync") .d("Download SUCCESS. Size: ${tempDownloadFile.length()}. Unpacking...") try { val jsonString = tempDownloadFile.readText() + val appliedAnnotationTimestamp = + annotationModifiedTimestamp.takeIf { it > 0L } + ?: tempDownloadFile.lastModified().takeIf { it > 0L } + ?: 0L Timber.d( "android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}" ) @@ -3358,16 +4205,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } catch (_: Exception) { false } + logCloudAnnotationSyncTrace { + "android.download.inspect book=$bookId isBundle=$isBundle rawBytes=${jsonString.length} " + + "appliedAnnTs=$appliedAnnotationTimestamp rawPreview=${jsonString.take(80).replace('\n', ' ')}" + } val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File( appContext.filesDir, "annotations/annotation_$bookId.json" ) + val deletedInkFile = File(appContext.filesDir, "annotations/deleted_annotation_$bookId.json") val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) val highlightFile = pdfHighlightRepository.getFileForSync(bookId) inkFile.parentFile?.mkdirs() + deletedInkFile.parentFile?.mkdirs() richTextFile.parentFile?.mkdirs() layoutFile.parentFile?.mkdirs() textBoxFile.parentFile?.mkdirs() @@ -3380,44 +4233,94 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d( "android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" ) + logCloudAnnotationSyncTrace { + "android.download.bundle_keys book=$bookId keys=${bundle.keys().asSequence().toList()} " + + "hasInk=${bundle.has("ink")} hasText=${bundle.has("text")} " + + "hasLayout=${bundle.has("layout")} hasTextBoxes=${bundle.has("textBoxes")} " + + "hasHighlights=${bundle.has("highlights")} " + + "hasDeletedInk=${bundle.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS)}" + } fun writeSafe(key: String, file: File) { if (bundle.has(key)) { file.parentFile?.mkdirs() val content = bundle.get(key).toString() file.writeText(content) + appliedAnnotationTimestamp.takeIf { it > 0L }?.let(file::setLastModified) + logCloudAnnotationSyncTrace { + "android.download.write key=$key book=$bookId bytes=${content.length} " + + "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}" + } if (key == "text") { Timber.d( "android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" ) } } else { + if (key == "layout") { + logCloudAnnotationSyncTrace { + "android.download.preserve_missing key=layout book=$bookId " + + "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}" + } + Timber.d( + "android.cloud.import.preserveMissingLayout book=$bookId file=${file.absolutePath}" + ) + return + } if (key == "text" && file.exists()) { Timber.d( "android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}" ) } - if (file.exists()) file.delete() + if (file.exists()) { + val deleted = file.delete() + logCloudAnnotationSyncTrace { + "android.download.delete_missing key=$key book=$bookId deleted=$deleted " + + "path=${file.absolutePath.cloudSyncPreview(140)}" + } + } else { + logCloudAnnotationSyncTrace { + "android.download.missing_key key=$key book=$bookId path=${file.absolutePath.cloudSyncPreview(140)}" + } + } } } writeSafe("ink", inkFile) + writeSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile) writeSafe("text", richTextFile) writeSafe("layout", layoutFile) writeSafe("textBoxes", textBoxFile) writeSafe("highlights", highlightFile) + logCloudSyncTrace { + "android.sidecar_download.applied_bundle book=$bookId remoteAnnTs=$annotationModifiedTimestamp " + + "keys=${bundle.keys().asSequence().toList()}" + } Timber.tag("AnnotationSync").d("Unpacked unified bundle.") } else { Timber.tag("AnnotationSync").d("Detected legacy format (Ink only).") inkFile.writeText(jsonString) + appliedAnnotationTimestamp.takeIf { it > 0L }?.let(inkFile::setLastModified) + logCloudAnnotationSyncTrace { + "android.download.write_legacy_ink book=$bookId bytes=${jsonString.length} " + + "path=${inkFile.absolutePath.cloudSyncPreview(140)} ts=${inkFile.lastModified()}" + } + logCloudSyncTrace { "android.sidecar_download.applied_legacy book=$bookId remoteAnnTs=$annotationModifiedTimestamp" } } } catch (e: Exception) { + logCloudAnnotationSyncError(e) { "android.download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" } + logCloudSyncError(e) { "android.sidecar_download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" } Timber.e(e, "Error unpacking synced annotation data") } finally { tempDownloadFile.delete() } } else { + logCloudAnnotationSyncTrace { + "android.download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp didDownload=$didDownload " + + "tempExists=${tempDownloadFile.exists()} tempBytes=${tempDownloadFile.length()}" + } + logCloudSyncTrace { "android.sidecar_download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp" } Timber.tag("AnnotationSync") .d("FAILURE: No bundle found on Drive for $bookId (or download failed)") } @@ -3611,7 +4514,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) } } - } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) { + } else if (uri.scheme != "opds-pse" && type in COMIC_ARCHIVE_FILE_TYPES) { if (coverPath == null) { var cacheFile: File? = null try { @@ -3641,7 +4544,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } archiveDoc.close() } catch (e: Exception) { - Timber.e(e, "Error generating CBZ cover") + Timber.e(e, "Error generating comic archive cover") } finally { try { if (cacheFile?.exists() == true) { @@ -3975,6 +4878,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val (internalUri, bookId, type) = importResult if (isExternalIntent) { externalOpenedBookId = bookId + if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") { + markPendingExternalFileRemoval(bookId, internalUri.toString()) + } } val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" openBook( @@ -4027,9 +4933,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri if (currentBookUri != null) { recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item -> + if (annotationJsonEquivalentForNoop(item.highlightsJson, highlightsJson)) { + logCloudSyncTrace { + "android.reader.highlights_save_noop book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}" + } + return@launch + } + logCloudSyncTrace { + "android.reader.highlights_save book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}" + } recentFilesRepository.updateHighlights(item.bookId, highlightsJson) } } else if (bookId.isNotBlank()) { + val existing = recentFilesRepository.getFileByBookId(bookId) + if (annotationJsonEquivalentForNoop(existing?.highlightsJson, highlightsJson)) { + logCloudSyncTrace { + "android.reader.highlights_save_noop book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}" + } + return@launch + } + logCloudSyncTrace { + "android.reader.highlights_save book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}" + } recentFilesRepository.updateHighlights(bookId, highlightsJson) } } @@ -4401,6 +5326,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { null } + logCloudSyncTrace { + "android.reader.open_epub_position book=$bookId " + + "overrideLocator=$initialLocatorOverride overrideCfi=${initialCfiOverride.cloudSyncPreview()} " + + (recentItem?.cloudSyncTraceSummary("recent") ?: "recent=null") + + " chosenLocator=$locator chosenCfi=${(initialCfiOverride ?: recentItem?.lastPositionCfi).cloudSyncPreview()}" + } _internalState.update { it.copy( @@ -4730,13 +5661,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) { Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator") viewModelScope.launch { - recentFilesRepository.getFileByUri(uri.toString())?.let { _ -> + recentFilesRepository.getFileByUri(uri.toString())?.let { existing -> + logCloudSyncTrace { + "android.reader.position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " + + "locator={chapter=${locator.chapterIndex} block=${locator.blockIndex} char=${locator.charOffset}} " + + "progress=$progress cfi=${cfiForWebView.cloudSyncPreview()}" + } recentFilesRepository.updateEpubReadingPosition( uriString = uri.toString(), locator = locator, cfiForWebView = cfiForWebView, progress = progress ) + val updated = recentFilesRepository.getFileByBookId(existing.bookId) + logCloudSyncTrace { + "android.reader.position_save_done beforeTs=${existing.lastModifiedTimestamp} " + + (updated?.cloudSyncTraceSummary("after") ?: "after=null") + } + queueCloudMetadataUpload(existing.bookId, reason = "epub_position") } } } @@ -4778,10 +5720,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } Timber.tag("PdfPositionDebug").i("ViewModel: Save request triggered | Page: $page | Total: $totalPages | Progress: $progress | URI: ${currentPdfUri.lastPathSegment}") viewModelScope.launch { - recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> + recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { existing -> + logCloudSyncTrace { + "android.reader.pdf_position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " + + "beforeReadTs=${existing.effectiveReadingPositionModifiedTimestamp()} page=$page progress=$progress" + } recentFilesRepository.updatePdfReadingPosition( uriString = currentPdfUri.toString(), page = page, progress = progress ) + val updated = recentFilesRepository.getFileByBookId(existing.bookId) + logCloudSyncTrace { + "android.reader.pdf_position_save_done beforeTs=${existing.lastModifiedTimestamp} " + + (updated?.cloudSyncTraceSummary("after") ?: "after=null") + } + queueCloudMetadataUpload(existing.bookId, reason = "pdf_position") } ?: run { Timber.tag("PdfPositionDebug").e("ViewModel: Save aborted. Could not resolve file item from URI in DB.") } @@ -4831,7 +5783,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun refreshLibrary() { val syncEnabled = _internalState.value.isSyncEnabled - val hasFolder = _internalState.value.syncedFolders.isNotEmpty() + val hasFolder = _internalState.value.syncedFolders.any { it.localSyncEnabled } if (!syncEnabled && !hasFolder) { Timber.d("Refresh skipped: No sync methods active.") @@ -4924,19 +5876,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun uploadNewBookAndMetadata(book: RecentFileItem) { - if (!uiState.value.isSyncEnabled) return + if (!uiState.value.isSyncEnabled) { + logCloudSyncTrace { "android.upload_content.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" } + return + } if (book.uriString?.startsWith("opds-pse") == true) { + logCloudSyncTrace { "android.upload_content.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping book content sync for OPDS stream book: ${book.displayName}") return } if (book.sourceFolderUri != null) { + logCloudSyncTrace { "android.upload_content.skip reason=folder_book ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping book content sync for local folder book: ${book.displayName}") return } if (book.isManualOnlyReaderFile()) { + logCloudSyncTrace { "android.upload_content.skip reason=manual_only ${book.cloudSyncTraceSummary()}" } Timber.d("Skipping book content sync for manual-only reader file: ${book.displayName}") return } @@ -4944,16 +5902,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { _internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) } try { - val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch + logCloudSyncTrace { "android.upload_content.start ${book.cloudSyncTraceSummary()}" } + val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run { + logCloudSyncTrace { "android.upload_content.skip reason=no_access_token ${book.cloudSyncTraceSummary()}" } + return@launch + } book.getUri()?.path?.let { path -> val file = File(path) if (file.exists()) { + logCloudSyncTrace { "android.upload_content.file book=${book.bookId} path=${path.cloudSyncPreview()} bytes=${file.length()}" } Timber.d("Uploading newly added book content: ${book.displayName}") val uploadedFile = googleDriveRepository.uploadFile( accessToken, book.bookId, file, book.type ) if (uploadedFile != null) { + logCloudSyncTrace { "android.upload_content.success book=${book.bookId} driveId=${uploadedFile.id}" } Timber.d("Upload successful, now syncing metadata for ${book.bookId}") val latestBookState = recentFilesRepository.getFileByBookId(book.bookId) if (latestBookState != null) { @@ -4962,13 +5926,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio uploadSingleBookMetadata(book) } } else { + logCloudSyncTrace { "android.upload_content.failed_null book=${book.bookId}" } Timber.e("Google Drive upload returned null for ${book.bookId}") } } else { + logCloudSyncTrace { "android.upload_content.skip reason=file_missing book=${book.bookId} path=${path.cloudSyncPreview()}" } Timber.w("File for new book upload does not exist at path: $path") } } } catch (e: Exception) { + logCloudSyncError(e) { "android.upload_content.failed ${book.cloudSyncTraceSummary()}" } Timber.e(e, "Failed to upload new book content for bookId: ${book.bookId}") } finally { _internalState.update { @@ -5029,8 +5996,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val newBehavior = if (keep) "KEEP" else "DELETE" setExternalFileBehavior(newBehavior) } - if (!keep) { - deleteBookPermanently(bookId) + if (keep) { + clearPendingExternalFileRemovals(setOf(bookId)) + } else { + deletePendingExternalFileRemoval(bookId, null) } _internalState.update { it.copy(showExternalFileSavePromptFor = null) } } @@ -5399,10 +6368,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio deviceId ) - val fileExtension = item.type.name.lowercase() - val fileName = "${item.bookId}.$fileExtension" - remoteFiles[fileName]?.id?.let { fileId -> - Timber.d("Deleting from Drive: $fileName") + sharedCloudBookContentFileName(item.bookId, item.type) + ?.let { fileName -> + remoteFiles[fileName]?.id?.let { fileId -> + Timber.d("Deleting from Drive: $fileName") + googleDriveRepository.deleteDriveFile(accessToken, fileId) + } + } + remoteFiles[cloudPdfAnnotationDriveFileName(item.bookId)]?.id?.let { fileId -> + Timber.d("Deleting annotation bundle from Drive: ${item.bookId}") googleDriveRepository.deleteDriveFile(accessToken, fileId) } @@ -6097,7 +7071,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_APP_OPEN_COUNT = "app_open_count" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time" - private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json" private const val MAX_FOLDER_LIMIT = 10 internal const val KEY_PINNED_HOME = "pinned_home_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books" @@ -6108,6 +7081,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id" private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" + private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name" private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled" @@ -6132,3 +7106,61 @@ private fun RecentFileItem.isManualOnlyReaderFile(): Boolean { private fun BookMetadata.isManualOnlyReaderFile(): Boolean { return isManualOnlyReaderFileName(displayName) } + +private fun RecentFileItem.withFreshLocalReadingPositionForCloudUpload( + latestLocal: RecentFileItem? +): RecentFileItem { + if (latestLocal == null || latestLocal.bookId != bookId) return this + val latestReadingTimestamp = latestLocal.effectiveReadingPositionModifiedTimestamp() + val currentReadingTimestamp = effectiveReadingPositionModifiedTimestamp() + val shouldRefresh = latestLocal.lastModifiedTimestamp > lastModifiedTimestamp || + latestReadingTimestamp > currentReadingTimestamp + if (!shouldRefresh) return this + + return latestLocal.copy( + fileSize = fileSize.takeIf { it > 0L } ?: latestLocal.fileSize, + fileContentModifiedTimestamp = maxOf(fileContentModifiedTimestamp, latestLocal.fileContentModifiedTimestamp), + isAvailable = isAvailable || latestLocal.isAvailable, + uriString = latestLocal.uriString ?: uriString, + bookmarksJson = latestLocal.bookmarksJson ?: bookmarksJson, + highlightsJson = latestLocal.highlightsJson ?: highlightsJson + ) +} + +private fun RecentFileItem.withCloudReadingPosition(remote: BookMetadata): RecentFileItem { + return copy( + lastChapterIndex = remote.lastChapterIndex, + lastPage = remote.lastPage, + lastPositionCfi = remote.lastPositionCfi, + locatorBlockIndex = remote.locatorBlockIndex, + locatorCharOffset = remote.locatorCharOffset, + progressPercentage = remote.progressPercentage, + readingPositionModifiedTimestamp = remote.effectiveReadingPositionModifiedTimestamp() + ) +} + +private fun RecentFileItem.withLocalReadingPosition(local: RecentFileItem): RecentFileItem { + return copy( + lastChapterIndex = local.lastChapterIndex, + lastPage = local.lastPage, + lastPositionCfi = local.lastPositionCfi, + locatorBlockIndex = local.locatorBlockIndex, + locatorCharOffset = local.locatorCharOffset, + progressPercentage = local.progressPercentage, + readingPositionModifiedTimestamp = local.effectiveReadingPositionModifiedTimestamp() + ) +} + +private fun RecentFileItem.withLocalStorageForCloudMetadata(local: RecentFileItem): RecentFileItem { + return copy( + uriString = local.uriString ?: uriString, + isAvailable = local.isAvailable || isAvailable, + coverImagePath = local.coverImagePath ?: coverImagePath, + sourceFolderUri = local.sourceFolderUri ?: sourceFolderUri, + fileSize = local.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = maxOf(local.fileContentModifiedTimestamp, fileContentModifiedTimestamp), + folderTextMetadataParsed = local.folderTextMetadataParsed || folderTextMetadataParsed, + folderCoverMetadataParsed = local.folderCoverMetadataParsed || folderCoverMetadataParsed, + tags = local.tags + ) +} diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index c3dc3ab..de2d411 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -50,11 +50,20 @@ class MetadataExtractionWorker( val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - val hasLegacy = prefs.contains("synced_folder_uri") - val hasNew = prefs.contains("synced_folders_list_json") + val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null) + ) + val enabledFolderUris = linkedFolders + .filter { it.localSyncEnabled } + .mapTo(mutableSetOf()) { it.uriString } - if (!hasLegacy && !hasNew) { - ReaderPerfLog.d("MetadataWorker skipped: no linked folders") + if (enabledFolderUris.isEmpty()) { + ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled") + return@withContext Result.success() + } + if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) { + ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri") return@withContext Result.success() } @@ -62,7 +71,9 @@ class MetadataExtractionWorker( val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata( sourceFolderUri = sourceFolderUri, limit = METADATA_WORKER_BOOK_BATCH_SIZE - ) + ).filter { item -> + item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris + } if (filesToProcess.isEmpty()) { ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}") diff --git a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt index 9e9e1d9..0aca394 100644 --- a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt +++ b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt @@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer 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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove 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.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.edit +import com.aryan.reader.shared.ui.ReaderMinimalSlider import kotlin.math.roundToInt private const val READER_PREFS_NAME = "reader_prefs" private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system" private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value" private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f -private const val MIN_CUSTOM_BRIGHTNESS = 0.05f +private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1 +private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100 +private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1 +private const val MIN_CUSTOM_BRIGHTNESS = 0.01f data class ReaderBrightnessSettings( val useSystemBrightness: Boolean = true, val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS ) { val safeCustomBrightness: Float - get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) + get() = normalizeReaderBrightness(customBrightness) +} + +internal fun normalizeReaderBrightness(brightness: Float): Float { + val percent = (brightness * 100f).roundToInt() + .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT) + return percent / 100f +} + +internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float { + val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt() + val nextPercent = (currentPercent + percentDelta) + .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT) + return nextPercent / 100f } fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings { @@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings { return ReaderBrightnessSettings( useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true), customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS) - .coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) + .let(::normalizeReaderBrightness) ) } @@ -162,17 +184,9 @@ fun ReaderBrightnessSheet( color = MaterialTheme.colorScheme.primary ) } - Slider( - value = settings.safeCustomBrightness, - onValueChange = { brightness -> - onSettingsChange( - settings.copy( - useSystemBrightness = false, - customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) - ) - ) - }, - valueRange = MIN_CUSTOM_BRIGHTNESS..1f + ReaderBrightnessControl( + settings = settings, + onSettingsChange = onSettingsChange ) Text( text = stringResource(R.string.reader_brightness_custom_desc), @@ -186,6 +200,67 @@ fun ReaderBrightnessSheet( } } +@Composable +private fun ReaderBrightnessControl( + settings: ReaderBrightnessSettings, + onSettingsChange: (ReaderBrightnessSettings) -> Unit +) { + val brightness = settings.safeCustomBrightness + val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS + val canIncrease = brightness < 1f + + fun updateBrightness(value: Float) { + onSettingsChange( + settings.copy( + useSystemBrightness = false, + customBrightness = normalizeReaderBrightness(value) + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton( + onClick = { + updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT)) + }, + enabled = canDecrease, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = stringResource(R.string.content_desc_decrease), + modifier = Modifier.size(18.dp) + ) + } + ReaderMinimalSlider( + value = brightness, + onValueChange = ::updateBrightness, + valueRange = MIN_CUSTOM_BRIGHTNESS..1f, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + thumbColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT)) + }, + enabled = canIncrease, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.content_desc_increase), + modifier = Modifier.size(18.dp) + ) + } + } +} + private fun Window.setReaderBrightness(brightness: Float) { attributes = attributes.apply { screenBrightness = brightness diff --git a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt index 9fb9623..df76113 100644 --- a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt +++ b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt @@ -56,6 +56,21 @@ internal fun shouldRenderReaderSlider( isSearchActive: Boolean ): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive +internal fun readerSliderStepPage( + currentPage: Int, + delta: Int, + minPage: Int, + maxPage: Int +): Int { + val lowerBound = min(minPage, maxPage) + val upperBound = max(minPage, maxPage) + val nextPage = currentPage.toLong() + delta.toLong() + + return nextPage + .coerceIn(lowerBound.toLong(), upperBound.toLong()) + .toInt() +} + internal fun readerSliderTogglePreferenceKey(bookId: String): String = READER_SLIDER_TOGGLE_PREFIX + bookId diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 71e3996..98a75d3 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -142,9 +142,12 @@ 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.shared.SharedLegalLinks +import com.aryan.reader.shared.SharedLegalProfile import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.RecentFileItem import com.aryan.reader.shared.SharedText +import com.aryan.reader.shared.sharedLegalLinksForProfile import com.aryan.reader.shared.ui.SharedMarkdownText import timber.log.Timber import java.text.SimpleDateFormat @@ -154,9 +157,25 @@ import kotlin.math.log10 import kotlin.math.pow import kotlin.math.roundToInt -internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html" -internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html" -internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html" +internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks { + val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD + return sharedLegalLinksForProfile(profile) +} + +internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl +internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl +internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl + +fun supportedFontMimeTypes(): Array = arrayOf( + "font/ttf", + "font/otf", + "font/woff2", + "application/x-font-ttf", + "application/x-font-otf", + "application/font-woff2", + "application/vnd.ms-opentype", + "application/x-font-opentype" +) class CustomTabUriHandler(private val context: Context) : UriHandler { override fun openUri(uri: String) { @@ -1237,53 +1256,55 @@ fun AboutDialog(onDismiss: () -> Unit) { subtitle = stringResource(R.string.about_github_desc), onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") } ) - } else { - AboutInfoRow( - icon = { - Icon( - imageVector = Icons.Outlined.Policy, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary - ) - }, - text = stringResource(R.string.legal_privacy_policy), - subtitle = stringResource(R.string.about_privacy_desc), - onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) } - ) Spacer(modifier = Modifier.height(10.dp)) - - AboutInfoRow( - icon = { - Icon( - imageVector = Icons.Outlined.Gavel, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary - ) - }, - text = stringResource(R.string.legal_terms_of_service), - subtitle = stringResource(R.string.about_terms_desc), - onClick = { uriHandler.openUri(TERMS_URL) } - ) - - Spacer(modifier = Modifier.height(10.dp)) - - AboutInfoRow( - icon = { - Icon( - imageVector = Icons.Outlined.FileOpen, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary - ) - }, - text = stringResource(R.string.legal_licenses), - subtitle = stringResource(R.string.about_licenses_desc), - onClick = { uriHandler.openUri(LICENSES_URL) } - ) } + + AboutInfoRow( + icon = { + Icon( + imageVector = Icons.Outlined.Policy, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + text = stringResource(R.string.legal_privacy_policy), + subtitle = stringResource(R.string.about_privacy_desc), + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) } + ) + + Spacer(modifier = Modifier.height(10.dp)) + + AboutInfoRow( + icon = { + Icon( + imageVector = Icons.Outlined.Gavel, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + text = stringResource(R.string.legal_terms_of_service), + subtitle = stringResource(R.string.about_terms_desc), + onClick = { uriHandler.openUri(TERMS_URL) } + ) + + Spacer(modifier = Modifier.height(10.dp)) + + AboutInfoRow( + icon = { + Icon( + imageVector = Icons.Outlined.FileOpen, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + text = stringResource(R.string.legal_licenses), + subtitle = stringResource(R.string.about_licenses_desc), + onClick = { uriHandler.openUri(LICENSES_URL) } + ) } }, confirmButton = { diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt index fa6c19e..001cd8b 100644 --- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt +++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt @@ -10,11 +10,13 @@ import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters +import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator 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.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.Tag as SharedTag +import com.aryan.reader.shared.toStablePositionCfi fun FileType.toSharedFileType(): SharedFileType = this @@ -29,11 +31,21 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this fun RecentFileItem.toSharedBookItem(): SharedBookItem { + return toSharedBookItem( + displayName = customName ?: displayName, + includeReaderAnnotations = true + ) +} + +private fun RecentFileItem.toSharedBookItem( + displayName: String, + includeReaderAnnotations: Boolean +): SharedBookItem { return SharedBookItem( id = bookId, path = uriString, type = type, - displayName = customName ?: displayName, + displayName = displayName, timestamp = timestamp, coverImagePath = coverImagePath, title = title, @@ -53,13 +65,22 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem { seriesName = seriesName, seriesIndex = seriesIndex, lastPageIndex = lastPage, + readerPosition = toSharedReaderLocatorOrNull(), tags = tags.map { it.toSharedTag() }, - readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + readerHighlights = if (includeReaderAnnotations) { + EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + } else { + emptyList() + }, + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp ) } fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem { - return toSharedBookItem().copy(displayName = displayName) + return toSharedBookItem( + displayName = displayName, + includeReaderAnnotations = false + ) } fun SharedBookItem.toRecentFileItem( @@ -67,36 +88,49 @@ fun SharedBookItem.toRecentFileItem( tagEntitiesById: Map = emptyMap() ): RecentFileItem { val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) } - return androidBooksById[id]?.copy(tags = resolvedTags) - ?.copy( + val positionCfi = readerPosition?.toSharedPositionCfi() + androidBooksById[id]?.let { existing -> + val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex + val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi + val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex + val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset + + if ( + existing.uriString == path && + existing.type == type && + existing.timestamp == timestamp && + existing.coverImagePath == coverImagePath && + existing.title == title && + existing.author == author && + existing.description == description && + existing.originalTitle == originalTitle && + existing.originalAuthor == originalAuthor && + existing.originalSeriesName == originalSeriesName && + existing.originalSeriesIndex == originalSeriesIndex && + existing.originalDescription == originalDescription && + existing.lastPage == lastPageIndex && + existing.progressPercentage == progressPercentage && + existing.isRecent == isRecent && + existing.sourceFolderUri == sourceFolder && + existing.fileSize == fileSize && + existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp && + existing.seriesName == seriesName && + existing.seriesIndex == seriesIndex && + existing.folderTextMetadataParsed == folderTextMetadataParsed && + existing.lastChapterIndex == mappedLastChapterIndex && + existing.lastPositionCfi == mappedLastPositionCfi && + existing.locatorBlockIndex == mappedLocatorBlockIndex && + existing.locatorCharOffset == mappedLocatorCharOffset && + existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp && + existing.tags == resolvedTags + ) { + return existing + } + + return existing.copy( uriString = path, type = type, - 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, - displayName = displayName, + displayName = existing.displayName, timestamp = timestamp, coverImagePath = coverImagePath, title = title, @@ -116,8 +150,70 @@ fun SharedBookItem.toRecentFileItem( seriesName = seriesName, seriesIndex = seriesIndex, folderTextMetadataParsed = folderTextMetadataParsed, + lastChapterIndex = mappedLastChapterIndex, + lastPositionCfi = mappedLastPositionCfi, + locatorBlockIndex = mappedLocatorBlockIndex, + locatorCharOffset = mappedLocatorCharOffset, + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp, tags = resolvedTags ) + } + + return RecentFileItem( + bookId = id, + uriString = path, + type = type, + 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, + lastChapterIndex = readerPosition?.chapterIndex, + lastPositionCfi = positionCfi, + locatorBlockIndex = readerPosition?.blockIndex, + locatorCharOffset = readerPosition?.charOffset, + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp, + tags = resolvedTags + ) +} + +private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? { + if ( + lastChapterIndex == null && + lastPage == null && + lastPositionCfi.isNullOrBlank() && + locatorBlockIndex == null && + locatorCharOffset == null + ) { + return null + } + return SharedReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = lastPage + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset + ) +} + +private fun SharedReaderLocator.toSharedPositionCfi(): String? { + return toStablePositionCfi() } fun TagEntity.toSharedTag(): SharedTag { @@ -167,8 +263,16 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef { fun ReaderScreenState.toSharedReaderScreenState( rawBooks: List = rawLibraryFiles, - dbTags: List = allTags + dbTags: List = allTags, + includeReaderAnnotations: Boolean = true ): SharedReaderScreenState { + fun RecentFileItem.toStateSharedBookItem(): SharedBookItem { + return toSharedBookItem( + displayName = customName ?: displayName, + includeReaderAnnotations = includeReaderAnnotations + ) + } + return SharedReaderScreenState( selectedBookId = selectedBookId, selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(), @@ -202,16 +306,16 @@ fun ReaderScreenState.toSharedReaderScreenState( isSearchActive = isSearchActive, isRefreshing = isRefreshing, reflowProgress = reflowProgress, - recentBooks = recentFiles.map { it.toSharedBookItem() }, - libraryBooks = allRecentFiles.map { it.toSharedBookItem() }, - rawLibraryBooks = rawBooks.map { it.toSharedBookItem() }, + recentBooks = recentFiles.map { it.toStateSharedBookItem() }, + libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() }, + rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() }, pinnedHomeBookIds = pinnedHomeBookIds, pinnedLibraryBookIds = pinnedLibraryBookIds, libraryFilters = libraryFilters, recentFilesLimit = recentFilesLimit, isTabsEnabled = isTabsEnabled, openTabIds = openTabIds, - openTabs = openTabs.map { it.toSharedBookItem() }, + openTabs = openTabs.map { it.toStateSharedBookItem() }, activeTabBookId = activeTabBookId, showExternalFileSavePromptFor = showExternalFileSavePromptFor, externalFileBehavior = externalFileBehavior, @@ -237,7 +341,14 @@ fun List.withResolvedTags( val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> entry.value.mapNotNull { tagsById[it.tagId] } } - return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) } + return map { item -> + val resolvedTags = bookTagsMap[item.bookId].orEmpty() + if (item.tags == resolvedTags) { + item + } else { + item.copy(tags = resolvedTags) + } + } } fun SharedReaderScreenState.toAndroidReaderScreenState( @@ -246,8 +357,11 @@ fun SharedReaderScreenState.toAndroidReaderScreenState( tagEntitiesById: Map = emptyMap() ): ReaderScreenState { val fallbackBooksById = rawLibraryBooks.associateBy { it.id } + val mappedBooksById = LinkedHashMap() fun SharedBookItem.toAndroidBook(): RecentFileItem { - return toRecentFileItem(androidBooksById, tagEntitiesById) + return mappedBooksById.getOrPut(id) { + toRecentFileItem(androidBooksById, tagEntitiesById) + } } fun bookById(bookId: String): RecentFileItem? { return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook() @@ -260,7 +374,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState( isAddingBooksToShelf = isAddingBooksToShelf, contextualActionShelfIds = selectedShelfIds, contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) }, - shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) }, + shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } }, openTabs = openTabs.map { it.toAndroidBook() }, openTabIds = openTabIds, activeTabBookId = activeTabBookId, @@ -272,13 +386,19 @@ fun SharedReaderScreenState.toAndroidReaderScreenState( fun SharedShelf.toAndroidShelf( androidBooksById: Map = emptyMap(), tagEntitiesById: Map = emptyMap() +): Shelf { + return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) } +} + +private fun SharedShelf.toAndroidShelf( + resolveBook: (SharedBookItem) -> RecentFileItem ): Shelf { return Shelf( id = id, name = name, type = type, - books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, - directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, + books = books.map(resolveBook), + directBooks = directBooks.map(resolveBook), parentShelfId = parentShelfId, childShelfIds = childShelfIds, depth = depth, diff --git a/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt b/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt new file mode 100644 index 0000000..db4b379 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt @@ -0,0 +1,108 @@ +package com.aryan.reader + +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber + +internal object SyncedFolderPrefs { + const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json" + const val KEY_LEGACY_SYNCED_FOLDER_URI = "synced_folder_uri" + const val KEY_LEGACY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time" + + fun decodeSyncedFolders( + jsonString: String?, + legacyUri: String?, + legacyLastScanTime: Long = 0L, + legacyNameResolver: (String) -> String = { it }, + syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES + ): List { + if (jsonString == null) { + return legacyUri + ?.takeIf { it.isNotBlank() } + ?.let { uri -> + listOf( + SyncedFolder( + uriString = uri, + name = legacyNameResolver(uri), + lastScanTime = legacyLastScanTime, + allowedFileTypes = syncableTypes, + localSyncEnabled = true + ) + ) + } + .orEmpty() + } + + return try { + val array = JSONArray(jsonString) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val uri = obj.optString("uri").takeIf { it.isNotBlank() } + if (uri == null) continue + val name = obj.optString("name").takeIf { it.isNotBlank() } ?: legacyNameResolver(uri) + add( + SyncedFolder( + uriString = uri, + name = name, + lastScanTime = obj.optLong("lastScanTime", 0L), + allowedFileTypes = decodeAllowedFileTypes(obj, syncableTypes), + localSyncEnabled = obj.optBoolean("localSyncEnabled", true) + ) + ) + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse synced folders JSON") + emptyList() + } + } + + fun encodeSyncedFolders( + folders: List, + syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES + ): String { + val jsonArray = JSONArray() + folders.forEach { folder -> + val obj = JSONObject() + obj.put("uri", folder.uriString) + obj.put("name", folder.name) + obj.put("lastScanTime", folder.lastScanTime) + obj.put("localSyncEnabled", folder.localSyncEnabled) + val typesArray = JSONArray() + folder.allowedFileTypes + .filter { it in syncableTypes } + .forEach { typesArray.put(it.name) } + obj.put("allowedFileTypes", typesArray) + jsonArray.put(obj) + } + return jsonArray.toString() + } + + fun isLocalSyncEnabled( + jsonString: String?, + legacyUri: String?, + folderUriString: String, + syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES + ): Boolean { + return decodeSyncedFolders( + jsonString = jsonString, + legacyUri = legacyUri, + syncableTypes = syncableTypes + ).firstOrNull { it.uriString == folderUriString }?.localSyncEnabled == true + } + + private fun decodeAllowedFileTypes( + obj: JSONObject, + syncableTypes: Set + ): Set { + if (!obj.has("allowedFileTypes")) return syncableTypes + val typesArray = obj.optJSONArray("allowedFileTypes") ?: return syncableTypes + return buildSet { + for (i in 0 until typesArray.length()) { + val type = runCatching { FileType.valueOf(typesArray.getString(i)) }.getOrNull() + if (type != null && type in syncableTypes) add(type) + } + } + } +} 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 15a2799..485929c 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 = 22, + version = 23, exportSchema = false ) @TypeConverters(FileTypeConverter::class) @@ -288,6 +288,25 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_22_23 = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0") + db.execSQL(""" + UPDATE recent_files + SET readingPositionModifiedTimestamp = lastModifiedTimestamp + WHERE lastModifiedTimestamp > 0 + AND ( + lastChapterIndex IS NOT NULL OR + lastPage IS NOT NULL OR + lastPositionCfi IS NOT NULL OR + locatorBlockIndex IS NOT NULL OR + locatorCharOffset IS NOT NULL OR + COALESCE(progressPercentage, 0) > 0 + ) + """) + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -301,7 +320,7 @@ abstract class AppDatabase : RoomDatabase() { 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_19_20, - MIGRATION_20_21, MIGRATION_21_22 + MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23 ) .fallbackToDestructiveMigration(false) .build() 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 889d5a1..b077d3b 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -8,6 +8,7 @@ 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.LOCAL_FOLDER_SYNC_DATA_DIR import com.aryan.reader.shared.localFolderSyncAnnotationFileName import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName import com.aryan.reader.shared.localFolderSyncMetadataFileName @@ -21,7 +22,7 @@ import timber.log.Timber object LocalSyncUtils { private const val TAG = "FolderSync" private const val ANNOTATION_SUFFIX = "_annotations" - private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData" + private const val SYNC_SUBFOLDER_NAME = LOCAL_FOLDER_SYNC_DATA_DIR private data class SyncFileEntry( val name: String, @@ -638,6 +639,21 @@ object LocalSyncUtils { } } + suspend fun deleteSyncDataFolder( + context: Context, + sourceFolderUri: Uri + ): Boolean = withContext(Dispatchers.IO) { + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext false + val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext true + if (!syncDir.isDirectory) return@withContext false + syncDir.delete() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to delete sync data folder") + false + } + } + suspend fun getAllFolderMetadata( context: Context, sourceFolderUri: Uri 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 08cc5fb..be5dbd1 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, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription 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, readingPositionModifiedTimestamp 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, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription 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, readingPositionModifiedTimestamp 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)") @@ -75,10 +75,10 @@ interface RecentFileDao { @Query("DELETE FROM recent_files") suspend fun clearAll() - @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") + @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId") suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long) - @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") + @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId") suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long) @Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") 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 26ace97..21be423 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -62,7 +62,8 @@ data class RecentFileEntity( @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 + @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null, + @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L ) data class RecentFileSummary( @@ -96,5 +97,6 @@ data class RecentFileSummary( @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 + @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null, + @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L ) 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 6276f79..a080409 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -57,9 +57,25 @@ data class RecentFileItem( val originalDescription: String? = null, val folderTextMetadataParsed: Boolean = false, val folderCoverMetadataParsed: Boolean = false, + val readingPositionModifiedTimestamp: Long = 0L, val tags: List = emptyList() ) +fun RecentFileItem.hasReadingPositionForSync(): Boolean { + return lastChapterIndex != null || + lastPage != null || + !lastPositionCfi.isNullOrBlank() || + locatorBlockIndex != null || + locatorCharOffset != null || + (progressPercentage ?: 0f) > 0f +} + +fun RecentFileItem.effectiveReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() } + ?: 0L +} + fun RecentFileEntity.toRecentFileItem(): RecentFileItem { return RecentFileItem( bookId = this.bookId, @@ -96,7 +112,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { originalSeriesIndex = this.originalSeriesIndex, originalDescription = this.originalDescription, folderTextMetadataParsed = this.folderTextMetadataParsed, - folderCoverMetadataParsed = this.folderCoverMetadataParsed + folderCoverMetadataParsed = this.folderCoverMetadataParsed, + readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp ) } @@ -136,7 +153,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex, originalDescription = this.originalDescription ?: this.description, folderTextMetadataParsed = this.folderTextMetadataParsed, - folderCoverMetadataParsed = this.folderCoverMetadataParsed + folderCoverMetadataParsed = this.folderCoverMetadataParsed, + readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp() ) } @@ -156,6 +174,7 @@ fun RecentFileItem.toBookMetadata(): BookMetadata { isRecent = this.isRecent, isDeleted = this.isDeleted, lastModifiedTimestamp = this.lastModifiedTimestamp, + readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp(), bookmarksJson = this.bookmarksJson, hasAnnotations = false, customName = this.customName, @@ -172,6 +191,27 @@ fun RecentFileItem.toBookMetadata(): BookMetadata { ) } +fun BookMetadata.hasReadingPositionForSync(): Boolean { + return lastChapterIndex != null || + lastPage != null || + !lastPositionCfi.isNullOrBlank() || + locatorBlockIndex != null || + locatorCharOffset != null || + (progressPercentage ?: 0f) > 0f +} + +fun BookMetadata.effectiveReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() } + ?: 0L +} + +fun BookMetadata.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long = 0L): Long { + return sidecarModifiedTimestamp.takeIf { it > 0L } + ?: annotationModifiedTimestamp.takeIf { it > 0L } + ?: 0L +} + fun BookMetadata.toRecentFileItem(): RecentFileItem { return RecentFileItem( bookId = this.bookId, @@ -203,7 +243,8 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem { originalAuthor = this.originalAuthor, originalSeriesName = this.originalSeriesName, originalSeriesIndex = this.originalSeriesIndex, - originalDescription = this.originalDescription + originalDescription = this.originalDescription, + readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp() ) } @@ -241,6 +282,7 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem { originalAuthor = this.originalAuthor, originalSeriesName = this.originalSeriesName, originalSeriesIndex = this.originalSeriesIndex, - originalDescription = this.originalDescription + originalDescription = this.originalDescription, + readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp ) } 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 673485e..3fcdf45 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -27,9 +27,15 @@ import android.net.Uri import androidx.core.net.toUri import com.aryan.reader.FileType import com.aryan.reader.ReaderPerfLog +import com.aryan.reader.SyncedFolderPrefs +import com.aryan.reader.cloudSyncPreview +import com.aryan.reader.cloudSyncTraceSummary +import com.aryan.reader.logCloudAnnotationSyncTrace +import com.aryan.reader.logCloudSyncTrace import com.aryan.reader.scaledToCanvasLimit import timber.log.Timber import com.aryan.reader.BookImporter +import com.aryan.reader.cloudSyncAnnotationSummary import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.pdf.PdfRichTextRepository import com.aryan.reader.epub.ImportedFileCache @@ -189,7 +195,20 @@ class RecentFilesRepository(private val context: Context) { !embeddedMetadataFileChanged && existingItem.hasEmbeddedMetadataChanges() - item.toRecentFileEntity().copy( + val incomingEntity = item.toRecentFileEntity() + val incomingReadingTimestamp = item.effectiveReadingPositionModifiedTimestamp() + val existingReadingTimestamp = existingItem.readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: existingItem.lastModifiedTimestamp.takeIf { + existingItem.lastChapterIndex != null || + existingItem.lastPage != null || + !existingItem.lastPositionCfi.isNullOrBlank() || + existingItem.locatorBlockIndex != null || + existingItem.locatorCharOffset != null || + (existingItem.progressPercentage ?: 0f) > 0f + } + ?: 0L + val incomingReadingWins = incomingReadingTimestamp >= existingReadingTimestamp + incomingEntity.copy( uriString = existingItem.uriString ?: item.uriString, isAvailable = existingItem.isAvailable || item.isAvailable, coverImagePath = if (folderFileChanged) { @@ -211,13 +230,13 @@ class RecentFilesRepository(private val context: Context) { } else { item.author ?: existingItem.author }, - lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex, - lastPage = item.lastPage ?: existingItem.lastPage, - lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi, - locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex, - locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset, + lastChapterIndex = if (incomingReadingWins) item.lastChapterIndex ?: existingItem.lastChapterIndex else existingItem.lastChapterIndex, + lastPage = if (incomingReadingWins) item.lastPage ?: existingItem.lastPage else existingItem.lastPage, + lastPositionCfi = if (incomingReadingWins) item.lastPositionCfi ?: existingItem.lastPositionCfi else existingItem.lastPositionCfi, + locatorBlockIndex = if (incomingReadingWins) item.locatorBlockIndex ?: existingItem.locatorBlockIndex else existingItem.locatorBlockIndex, + locatorCharOffset = if (incomingReadingWins) item.locatorCharOffset ?: existingItem.locatorCharOffset else existingItem.locatorCharOffset, bookmarks = item.bookmarksJson ?: existingItem.bookmarks, - progressPercentage = item.progressPercentage ?: existingItem.progressPercentage, + progressPercentage = if (incomingReadingWins) item.progressPercentage ?: existingItem.progressPercentage else existingItem.progressPercentage, isRecent = item.isRecent, isDeleted = item.isDeleted, sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, @@ -259,13 +278,26 @@ class RecentFilesRepository(private val context: Context) { item.folderCoverMetadataParsed } else { item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed - } + }, + readingPositionModifiedTimestamp = maxOf(incomingReadingTimestamp, existingReadingTimestamp) ) } else { item.toRecentFileEntity() } Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}") + logCloudSyncTrace { + "android.db.upsert book=${item.bookId} ${item.cloudSyncTraceSummary("incoming")} " + + "existingTs=${existingItem?.lastModifiedTimestamp} existingPage=${existingItem?.lastPage} " + + "existingReadTs=${existingItem?.readingPositionModifiedTimestamp} " + + "existingChapter=${existingItem?.lastChapterIndex} finalTs=${entityToInsert.lastModifiedTimestamp} " + + "finalReadTs=${entityToInsert.readingPositionModifiedTimestamp} " + + "finalPage=${entityToInsert.lastPage} finalChapter=${entityToInsert.lastChapterIndex} " + + "finalBlock=${entityToInsert.locatorBlockIndex} finalChar=${entityToInsert.locatorCharOffset} " + + "finalProgress=${entityToInsert.progressPercentage} finalCfi=${entityToInsert.lastPositionCfi.cloudSyncPreview()} " + + "finalBookmarks=${entityToInsert.bookmarks.cloudSyncAnnotationSummary()} " + + "finalHighlights=${entityToInsert.highlights.cloudSyncAnnotationSummary()}" + } recentFileDao.insertOrUpdateFile(entityToInsert) Timber.d("Added/Updated recent file in DB: ${item.displayName}") } @@ -328,6 +360,11 @@ class RecentFilesRepository(private val context: Context) { val folderUriString = entity.sourceFolderUri if (folderUriString != null) { + if (!isLocalFolderSyncEnabled(folderUriString)) { + Timber.d("SyncDebug: Folder sync disabled for $folderUriString. Skipping metadata sidecar.") + return@withContext + } + val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f) val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]" val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]" @@ -385,6 +422,10 @@ class RecentFilesRepository(private val context: Context) { Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId") return@withContext } + if (!isLocalFolderSyncEnabled(folderUriString)) { + Timber.tag("FolderAnnotationSync").d("Folder sync disabled for $folderUriString. Skipping annotation sidecar.") + return@withContext + } val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) val richTextFile = pdfRichTextRepository.getFileForSync(bookId) @@ -466,12 +507,20 @@ class RecentFilesRepository(private val context: Context) { ) } - suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) { + suspend fun importAnnotationBundle( + bookId: String, + jsonString: String, + lastModifiedTimestamp: Long? = null + ) = withContext(Dispatchers.IO) { Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId") try { val bundle = JSONObject( SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) ) + logCloudAnnotationSyncTrace { + "android.repository.import_bundle book=$bookId remoteTs=${lastModifiedTimestamp ?: 0L} " + + "rawBytes=${jsonString.length} keys=${bundle.keys().asSequence().toList()}" + } Timber.d( "android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " + "hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" @@ -482,12 +531,22 @@ class RecentFilesRepository(private val context: Context) { file.parentFile?.mkdirs() val contentStr = bundle.get(key).toString() file.writeText(contentStr) + lastModifiedTimestamp?.takeIf { it > 0L }?.let(file::setLastModified) + logCloudAnnotationSyncTrace { + "android.repository.import_write key=$key book=$bookId bytes=${contentStr.length} " + + "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}" + } if (key == "text") { Timber.d( "android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}" ) } Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)") + } else if (file != null) { + logCloudAnnotationSyncTrace { + "android.repository.import_missing_key key=$key book=$bookId " + + "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}" + } } } @@ -496,6 +555,10 @@ class RecentFilesRepository(private val context: Context) { context.filesDir, "annotations/annotation_$bookId.json" ) writeSafe("ink", inkFile) + writeSafe( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, + File(context.filesDir, "annotations/deleted_annotation_$bookId.json") + ) // 2. Text writeSafe("text", pdfRichTextRepository.getFileForSync(bookId)) @@ -606,6 +669,15 @@ class RecentFilesRepository(private val context: Context) { Timber.d("Detached all folder books. They are now standard local files.") } + private fun isLocalFolderSyncEnabled(folderUriString: String): Boolean { + val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + return SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), + folderUriString = folderUriString + ) + } + suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) { val currentTime = System.currentTimeMillis() recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime) @@ -618,6 +690,9 @@ class RecentFilesRepository(private val context: Context) { val currentTime = System.currentTimeMillis() recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime) Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime") + logCloudSyncTrace { + "android.repository.pdf_position_update book=${item.bookId} page=$page progress=$progress ts=$currentTime" + } } else { Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString") } diff --git a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt index 12a06e4..81a9bf9 100644 --- a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt +++ b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt @@ -15,8 +15,8 @@ import timber.log.Timber import java.io.ByteArrayInputStream import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.util.zip.ZipInputStream -import javax.xml.parsers.DocumentBuilderFactory data class CalibreBundleResult( val internalBookUri: Uri, @@ -89,7 +89,7 @@ object CalibreBundleExtractor { if (tempBookFile != null && opfData != null && extractedType != null) { val finalBookFile = bookImporter.createBookFile("$bookId.$ext") - tempBookFile!!.renameTo(finalBookFile) + moveExtractedBook(tempBookFile!!, finalBookFile) var coverPath: String? = null if (coverBytes != null) { @@ -106,7 +106,7 @@ object CalibreBundleExtractor { var seriesIndex: Double? = null try { - val factory = DocumentBuilderFactory.newInstance() + val factory = secureDocumentBuilderFactory() val builder = factory.newDocumentBuilder() val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8))) val metadataNodes = document.getElementsByTagName("metadata") @@ -159,8 +159,25 @@ object CalibreBundleExtractor { } catch (e: Exception) { Timber.e(e, "Failed to process zip bundle") } finally { - tempBookFile?.delete() // Cleanup if parsing failed midway + tempBookFile?.takeIf { it.exists() }?.delete() } return@withContext null } -} \ No newline at end of file + + private fun moveExtractedBook(tempBookFile: File, finalBookFile: File) { + finalBookFile.parentFile?.mkdirs() + if (finalBookFile.exists() && !finalBookFile.delete()) { + throw IOException("Could not replace existing book file: ${finalBookFile.absolutePath}") + } + if (tempBookFile.renameTo(finalBookFile)) return + + tempBookFile.inputStream().use { input -> + FileOutputStream(finalBookFile).use { output -> + input.copyTo(output) + } + } + if (!finalBookFile.isFile) { + throw IOException("Could not move extracted book to: ${finalBookFile.absolutePath}") + } + } +} diff --git a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt b/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt index ad80edf..c451ca8 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt @@ -33,5 +33,10 @@ data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor( @ProtoNumber(5) val plainTextContent: String, @ProtoNumber(6) val htmlContent: String, @ProtoNumber(7) val depth: Int = 0, - @ProtoNumber(8) val isInToc: Boolean = true -) \ No newline at end of file + @ProtoNumber(8) val isInToc: Boolean = true, + @ProtoNumber(9) val plainTextLength: Int = plainTextContent.length +) + +fun EpubChapter.plainTextCharacterCount(): Int { + return maxOf(plainTextLength, plainTextContent.length) +} diff --git a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt b/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt index 9cffed3..ff23961 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt @@ -23,22 +23,53 @@ import org.w3c.dom.Document import org.w3c.dom.Element import org.w3c.dom.Node import org.w3c.dom.NodeList +import java.io.File import java.io.InputStream +import javax.xml.XMLConstants import javax.xml.parsers.DocumentBuilderFactory fun parseXMLFile(inputSteam: InputStream): Document? = - DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam) + secureDocumentBuilderFactory().newDocumentBuilder().parse(inputSteam) fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream()) fun String.asFileName(): String = this.replace("/", "_") +internal fun secureDocumentBuilderFactory(): DocumentBuilderFactory { + return DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = false + setFeatureSafely(XMLConstants.FEATURE_SECURE_PROCESSING, true) + setFeatureSafely("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeatureSafely("http://xml.org/sax/features/external-general-entities", false) + setFeatureSafely("http://xml.org/sax/features/external-parameter-entities", false) + setFeatureSafely("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + runCatching { isXIncludeAware = false } + runCatching { isExpandEntityReferences = false } + } +} + +private fun DocumentBuilderFactory.setFeatureSafely(name: String, value: Boolean) { + runCatching { setFeature(name, value) } +} + +internal fun safeFileInRoot(root: File, childPath: String): File? { + val rootFile = runCatching { root.canonicalFile }.getOrNull() ?: return null + val targetFile = runCatching { File(rootFile, childPath).canonicalFile }.getOrNull() ?: return null + return targetFile.takeIf { it.isInsideOrSame(rootFile) } +} + +internal fun File.isInsideOrSame(root: File): Boolean { + val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false + val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false + return targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) +} + fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0) fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag } fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag } fun Node.getAttributeValue(attribute: String): String? = attributes?.getNamedItem(attribute)?.textContent -val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element } +val NodeList.elements get() = (0 until length).asSequence().mapNotNull { item(it) as? Element } val Node.childElements get() = childNodes.elements diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index 458b37f..404467b 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -12,6 +12,7 @@ import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.InputStream +import java.security.MessageDigest import java.util.zip.ZipInputStream class Fb2Parser(private val context: Context) { @@ -210,7 +211,12 @@ class Fb2Parser(private val context: Context) { if (!inBody) { if (coverImageId == null) coverImageId = id } else { - currentChapterHtml.append("") + val safeImageName = safeResourceFileName(id) + if (safeImageName != null) { + currentChapterHtml.append("") + } else { + Timber.w("Skipping unsafe FB2 image reference: $id") + } } } } @@ -220,12 +226,23 @@ class Fb2Parser(private val context: Context) { val base64Data = parser.nextText() try { val bytes = Base64.decode(base64Data, Base64.DEFAULT) + val safeId = safeResourceFileName(id) if (parseContent) { - val imgFile = File(extractionDir, id) - FileOutputStream(imgFile).use { it.write(bytes) } + if (safeId != null) { + val imgFile = safeFileInRoot(extractionDir, safeId) + if (imgFile != null) { + FileOutputStream(imgFile).use { it.write(bytes) } + } else { + Timber.w("Skipping unsafe FB2 binary path: $id") + } + } else { + Timber.w("Skipping unsafe FB2 binary id: $id") + } } - images.add(EpubImage(absPath = id)) + if (safeId != null) { + images.add(EpubImage(absPath = safeId)) + } if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) { coverBytes = bytes @@ -326,4 +343,30 @@ class Fb2Parser(private val context: Context) { } } } + + private fun safeResourceFileName(id: String): String? { + val rawName = id.substringAfterLast('/').substringAfterLast('\\').trim() + if (rawName.isBlank() || rawName == "." || rawName == "..") return null + + val extension = rawName.substringAfterLast('.', missingDelimiterValue = "") + .takeIf { it.isNotBlank() && it.length <= 12 } + ?.replace(Regex("[^A-Za-z0-9]"), "") + .orEmpty() + val baseName = rawName.substringBeforeLast('.', rawName) + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('.', '_', '-') + .ifBlank { "image" } + .take(48) + val suffix = sha256Hex(id).take(12) + return if (extension.isBlank()) { + "${baseName}_$suffix" + } else { + "${baseName}_$suffix.$extension" + } + } + + private fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } } diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt index 4451f12..9956b5c 100644 --- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt @@ -184,9 +184,13 @@ class OdtParser(private val context: Context) { "Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes() else -> { if (entry.name !in ignoredFiles) { - val extractedFile = File(extractionDir, entry.name) - extractedFile.parentFile?.mkdirs() - FileOutputStream(extractedFile).use { out -> zis.copyTo(out) } + val extractedFile = safeFileInRoot(extractionDir, entry.name) + if (extractedFile != null) { + extractedFile.parentFile?.mkdirs() + FileOutputStream(extractedFile).use { out -> zis.copyTo(out) } + } else { + Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}") + } } } } @@ -371,10 +375,18 @@ class OdtParser(private val context: Context) { if (isFlat) { try { val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT) - val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png" - val imgFile = File(extractionDir, imgName) - FileOutputStream(imgFile).use { it.write(bytes) } - currentChapterHtml.append("") + val imgName = currentImageHref + ?.substringAfterLast("/") + ?.substringAfterLast("\\") + ?.takeIf { it.isNotBlank() } + ?: "${UUID.randomUUID()}.png" + val imgFile = safeFileInRoot(extractionDir, imgName) + if (imgFile != null) { + FileOutputStream(imgFile).use { it.write(bytes) } + currentChapterHtml.append("") + } else { + Timber.w("Skipping unsafe FODT image path: $imgName") + } } catch (e: Exception) { Timber.e(e, "Failed to decode FODT image") } diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 0d6c679..e86a0d1 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -56,6 +56,8 @@ class SingleFileImporter(private val context: Context) { private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000 private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000 private const val MAX_HTML_INLINE_CSS_CHARS = 256_000 + private const val MAX_SINGLE_FILE_METADATA_BYTES = 2L * 1024L * 1024L + private const val BOOK_METADATA_FILE = "book_metadata.json" private const val PAGE_BREAK_MARKER = "" } @@ -68,6 +70,62 @@ class SingleFileImporter(private val context: Context) { private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false) + private fun metadataFile(extractionDir: File): File = File(extractionDir, BOOK_METADATA_FILE) + + private fun EpubBook.lightweightSingleFileCache(): EpubBook { + val cacheChapters = chapters.map { chapter -> + chapter.copy( + plainTextContent = "", + htmlContent = "" + ) + } + return copy( + coverImage = null, + chapters = cacheChapters, + chaptersForPagination = cacheChapters + ) + } + + private fun readCachedSingleFileBook(metadataFile: File, extractionDir: File, tag: String): EpubBook? { + if (!metadataFile.exists()) return null + if (metadataFile.length() > MAX_SINGLE_FILE_METADATA_BYTES) { + Timber.w( + "Ignoring oversized $tag metadata cache (${metadataFile.length()} bytes). " + + "The file will be reparsed with lightweight metadata." + ) + runCatching { metadataFile.delete() } + return null + } + + return try { + val decodedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + val cacheChapters = decodedBook.chapters.map { it.copy(htmlContent = "") } + decodedBook.copy( + chapters = cacheChapters, + chaptersForPagination = cacheChapters, + extractionBasePath = extractionDir.absolutePath + ).takeIf { it.hasReadableExtractedContent() } + } catch (e: OutOfMemoryError) { + Timber.e(e, "Failed to load cached $tag metadata without exhausting memory") + runCatching { metadataFile.delete() } + null + } catch (e: Exception) { + Timber.e(e, "Failed to load cached $tag, parsing again") + null + } + } + + private fun writeSingleFileMetadata(metadataFile: File, book: EpubBook, tag: String) { + try { + metadataFile.writeText(jsonSerializer.encodeToString(book.lightweightSingleFileCache())) + } catch (e: OutOfMemoryError) { + Timber.e(e, "Failed to cache lightweight $tag metadata without exhausting memory") + runCatching { metadataFile.delete() } + } catch (e: Exception) { + Timber.e(e, "Failed to cache $tag metadata") + } + } + suspend fun importSingleFile( inputStream: InputStream, type: FileType, @@ -195,17 +253,11 @@ class SingleFileImporter(private val context: Context) { } val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) - val metadataFile = File(extractionDir, "book_metadata.json") + val metadataFile = metadataFile(extractionDir) - if (metadataFile.exists()) { - try { - val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) - .copy(extractionBasePath = extractionDir.absolutePath) - Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId") - return@withContext cachedBook - } catch (e: Exception) { - Timber.e(e, "Failed to load cached MD, parsing again") - } + readCachedSingleFileBook(metadataFile, extractionDir, "MD")?.let { cachedBook -> + Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook } ImportedFileCache.resetActiveBookDir(context, bookId) @@ -299,11 +351,7 @@ class SingleFileImporter(private val context: Context) { css = emptyMap() ) - try { - metadataFile.writeText(jsonSerializer.encodeToString(book)) - } catch (e: Exception) { - Timber.e(e, "Failed to cache MD metadata") - } + writeSingleFileMetadata(metadataFile, book, "MD") return@withContext book } @@ -331,17 +379,11 @@ class SingleFileImporter(private val context: Context) { } val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) - val metadataFile = File(extractionDir, "book_metadata.json") + val metadataFile = metadataFile(extractionDir) - if (metadataFile.exists()) { - try { - val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) - .copy(extractionBasePath = extractionDir.absolutePath) - Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId") - return@withContext cachedBook - } catch (e: Exception) { - Timber.e(e, "Failed to load cached TXT, parsing again") - } + readCachedSingleFileBook(metadataFile, extractionDir, "TXT")?.let { cachedBook -> + Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook } ImportedFileCache.resetActiveBookDir(context, bookId) @@ -462,11 +504,7 @@ class SingleFileImporter(private val context: Context) { css = emptyMap() ) - try { - metadataFile.writeText(jsonSerializer.encodeToString(book)) - } catch (e: Exception) { - Timber.e(e, "Failed to cache TXT metadata") - } + writeSingleFileMetadata(metadataFile, book, "TXT") return@withContext book } @@ -494,17 +532,11 @@ class SingleFileImporter(private val context: Context) { } val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) - val metadataFile = File(extractionDir, "book_metadata.json") + val metadataFile = metadataFile(extractionDir) - if (metadataFile.exists()) { - try { - val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) - .copy(extractionBasePath = extractionDir.absolutePath) - Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId") - return@withContext cachedBook - } catch (e: Exception) { - Timber.e(e, "Failed to load cached HTML, parsing again") - } + readCachedSingleFileBook(metadataFile, extractionDir, "HTML")?.let { cachedBook -> + Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook } ImportedFileCache.resetActiveBookDir(context, bookId) @@ -699,11 +731,7 @@ class SingleFileImporter(private val context: Context) { css = emptyMap() ) - try { - metadataFile.writeText(jsonSerializer.encodeToString(book)) - } catch (e: Exception) { - Timber.e(e, "Failed to cache HTML metadata") - } + writeSingleFileMetadata(metadataFile, book, "HTML") return@withContext book } @@ -783,17 +811,11 @@ class SingleFileImporter(private val context: Context) { } val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) - val metadataFile = File(extractionDir, "book_metadata.json") + val metadataFile = metadataFile(extractionDir) - if (metadataFile.exists()) { - try { - val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) - .copy(extractionBasePath = extractionDir.absolutePath) - Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId") - return@withContext cachedBook - } catch (e: Exception) { - Timber.e(e, "Failed to load cached DOCX, parsing again") - } + readCachedSingleFileBook(metadataFile, extractionDir, "DOCX")?.let { cachedBook -> + Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook } ImportedFileCache.resetActiveBookDir(context, bookId) 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 34001a3..84a7e93 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -100,6 +100,7 @@ import java.io.InputStreamReader private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter" +private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag" private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( "PageInfoReporter", "ProgressReporter", @@ -386,6 +387,36 @@ private data class CustomMenuState( val selectedColor: HighlightColor? = null ) +internal fun highlightsJsonForWebView(userHighlights: List): String { + val jsonArray = org.json.JSONArray() + userHighlights.forEach { highlight -> + val obj = JSONObject() + obj.put("id", highlight.id) + obj.put("cfi", highlight.cfi) + obj.put("text", highlight.text) + obj.put("cssClass", highlight.color.cssClass) + obj.put("colorId", highlight.color.id) + obj.put("chapterIndex", highlight.chapterIndex) + obj.put( + "locator", + JSONObject().apply { + highlight.locator.chapterIndex?.let { put("chapterIndex", it) } + highlight.locator.chapterId?.let { put("chapterId", it) } + highlight.locator.href?.let { put("href", it) } + highlight.locator.pageIndex?.let { put("pageIndex", it) } + highlight.locator.startOffset?.let { put("startOffset", it) } + highlight.locator.endOffset?.let { put("endOffset", it) } + highlight.locator.blockIndex?.let { put("blockIndex", it) } + highlight.locator.charOffset?.let { put("charOffset", it) } + highlight.locator.textQuote?.let { put("textQuote", it) } + highlight.locator.cfi?.let { put("cfi", it) } + } + ) + jsonArray.put(obj) + } + return jsonArray.toString() +} + @Suppress("unused") class AiJsBridge( private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit @@ -526,17 +557,7 @@ fun ChapterWebView( ) } - val highlightsJson = remember(userHighlights) { - val jsonArray = org.json.JSONArray() - userHighlights.forEach { h -> - val obj = JSONObject() - obj.put("cfi", h.cfi) - obj.put("text", h.text) - obj.put("cssClass", h.color.cssClass) - jsonArray.put(obj) - } - jsonArray.toString() - } + val highlightsJson = remember(userHighlights) { highlightsJsonForWebView(userHighlights) } if (showExternalLinkDialog != null) { val urlToShow = showExternalLinkDialog!! @@ -719,6 +740,11 @@ fun ChapterWebView( ) } + message.startsWith("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG:") -> { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG) + .d("JS -> ${message.substringAfter("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG: ")}") + } + message.startsWith("ReaderFontDiagnosis") -> { Timber.d( "JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}" diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index 56fead4..3390303 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -61,6 +61,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -71,6 +72,7 @@ import androidx.core.text.HtmlCompat import com.aryan.reader.R import com.aryan.reader.epub.EpubChapter import com.aryan.reader.shared.EpubAnnotationSerializer +import com.aryan.reader.shared.ReaderLocator private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks" @@ -155,14 +157,20 @@ fun processAndAddHighlight( newText: String, newColor: HighlightColor, chapterIndex: Int, - currentList: MutableList + currentList: MutableList, + locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = newCfi, + textQuote = newText + ) ): String { return EpubAnnotationSerializer.processAndAddHighlight( newCfi = newCfi, newText = newText, newColor = newColor, chapterIndex = chapterIndex, - currentList = currentList + currentList = currentList, + locator = locator ) } @@ -593,6 +601,7 @@ fun HighlightColorRow( modifier = Modifier .padding(horizontal = 4.dp) .size(28.dp) + .testTag("HighlightColor_${colorEnum.id}") .clip(CircleShape) // 1. Clip shape for ripple .background(colorEnum.color) // 2. Apply background .clickable { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt index 0e9d8c6..e67e4e8 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt @@ -21,15 +21,17 @@ package com.aryan.reader.epubreader import android.content.Context import com.aryan.reader.R -import timber.log.Timber +import com.aryan.reader.applyBookReplacementsToHtmlDocument import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.LocatorConverter +import com.aryan.reader.shared.ReaderBookReplacementPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.nodes.Node +import timber.log.Timber import java.io.File data class ChapterLoadingResult( @@ -86,7 +88,9 @@ suspend fun loadChapterContent( chunkTargetOverride: Int?, isInitialCfiLoad: Boolean, cfiToLoad: String?, - locatorConverter: LocatorConverter + locatorConverter: LocatorConverter, + bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + bookReplacementFileId: String? = null, ): ChapterLoadingResult = withContext(Dispatchers.IO) { val chapter = epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult( @@ -100,6 +104,11 @@ suspend fun loadChapterContent( val doc = Jsoup.parse(htmlFile, "UTF-8") val head = doc.head().html() doc.select("script").remove() + applyBookReplacementsToHtmlDocument( + document = doc, + preferences = bookReplacementPreferences, + fileId = bookReplacementFileId, + ) val bodyNodes = doc.body().childNodes().toList() val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes) if (htmlChunks.isEmpty()) { 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 c3799da..09ffe2c 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -1,30 +1,6 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ -// EpubReaderControls.kt package com.aryan.reader.epubreader -import android.annotation.SuppressLint -import android.graphics.Bitmap -import android.graphics.Canvas import android.os.Build -import android.webkit.WebView import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.foundation.lazy.LazyListState @@ -38,14 +14,12 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource 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.PaddingValues import androidx.compose.foundation.layout.Row @@ -56,6 +30,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.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only @@ -72,6 +47,8 @@ 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.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowUpward @@ -82,6 +59,10 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause @@ -121,9 +102,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -136,7 +115,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex -import androidx.core.graphics.createBitmap import androidx.media3.common.util.UnstableApi import com.aryan.reader.BuildConfig import com.aryan.reader.R @@ -145,15 +123,13 @@ import com.aryan.reader.SearchState import com.aryan.reader.SearchTopBar import com.aryan.reader.TooltipIconButton import com.aryan.reader.areReaderAiFeaturesEnabled -import com.aryan.reader.epub.EpubChapter import com.aryan.reader.loadNativeVoice -import com.aryan.reader.paginatedreader.BookPaginator -import com.aryan.reader.paginatedreader.IPaginator +import com.aryan.reader.readerSliderStepPage +import com.aryan.reader.shared.ui.ReaderMinimalSlider import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS +import com.aryan.reader.tts.ReaderTtsOverlaySize import com.aryan.reader.tts.TtsPlaybackManager.TtsState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber +import com.aryan.reader.tts.formatReaderTtsChunkLabel import kotlin.math.roundToInt enum class ReaderTool(@StringRes val titleRes: Int, val category: String) { @@ -177,7 +153,8 @@ enum class ReaderTool(@StringRes val titleRes: Int, val category: String) { SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"), AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"), TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"), - TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu") + TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"), + BOOK_REPLACEMENTS(R.string.menu_book_word_replacements, "Overflow Menu") } enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } @@ -286,6 +263,7 @@ internal enum class EpubOverflowMenuSection { KEEP_SCREEN_ON, VISUAL_OPTIONS, AUTO_SCROLL, + BOOK_REPLACEMENTS, TTS_SETTINGS, FILE_INFO } @@ -309,6 +287,7 @@ internal fun epubOverflowMenuSections( if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON) if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS) if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL) + if (!hiddenTools.contains(ReaderTool.BOOK_REPLACEMENTS.name)) add(EpubOverflowMenuSection.BOOK_REPLACEMENTS) if ( !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) || !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name) @@ -381,11 +360,13 @@ fun EpubReaderTopBar( volumeScrollEnabled: Boolean, isPageTurnAnimationEnabled: Boolean, isRightToLeftPagination: Boolean, + useNativeVerticalRenderer: Boolean, onNavigateBack: () -> Unit, isKeepScreenOn: Boolean, onToggleKeepScreenOn: (Boolean) -> Unit, onCloseSearch: () -> Unit, onChangeRenderMode: (RenderMode) -> Unit, + onUseNativeVerticalRendererChange: (Boolean) -> Unit, onToggleBookmark: () -> Unit, onToggleTapToNavigate: (Boolean) -> Unit, onToggleVolumeScroll: (Boolean) -> Unit, @@ -394,6 +375,7 @@ fun EpubReaderTopBar( onStartAutoScroll: () -> Unit, onOpenTtsSettings: () -> Unit, onOpenTtsReplacements: () -> Unit, + onOpenBookReplacements: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, onOpenBrightness: () -> Unit, @@ -701,14 +683,29 @@ fun EpubReaderTopBar( ) if (showReadingModeExpanded) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + text = { Text(stringResource(R.string.menu_reading_mode_vertical_webview)) }, enabled = !isTtsActive, onClick = { + onUseNativeVerticalRendererChange(false) showMoreMenu = false onChangeRenderMode(RenderMode.VERTICAL_SCROLL) }, trailingIcon = { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( + if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !useNativeVerticalRenderer) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical_native)) }, + enabled = !isTtsActive, + onClick = { + onUseNativeVerticalRendererChange(true) + showMoreMenu = false + onChangeRenderMode(RenderMode.VERTICAL_SCROLL) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer) Icon( Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected) ) @@ -849,6 +846,22 @@ fun EpubReaderTopBar( onStartAutoScroll() }) } + EpubOverflowMenuSection.BOOK_REPLACEMENTS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_book_word_replacements)) }, + onClick = { + showMoreMenu = false + onOpenBookReplacements() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.text_fields), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } EpubOverflowMenuSection.TTS_SETTINGS -> { DropdownMenuItem( text = { Text(stringResource(R.string.menu_tts_settings)) }, @@ -1158,26 +1171,18 @@ fun EpubReaderBottomBar( } @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) -@SuppressLint("UnusedBoxWithConstraintsScope") -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EpubReaderPageSlider( isVisible: Boolean, - currentRenderMode: RenderMode, totalPages: Int, sliderCurrentPage: Float, sliderStartPage: Int, - startPageThumbnail: Bitmap?, - paginator: IPaginator?, - chapters: List, onScrub: (Float) -> Unit, onJumpToPage: (Int) -> Unit, modifier: Modifier = Modifier, activeColor: Color = Color.Unspecified, inactiveColor: Color = Color.Unspecified, - contentColor: Color = Color.Unspecified, - thumbnailSurfaceColor: Color = Color.Unspecified, - thumbnailContentColor: Color = Color.Unspecified + contentColor: Color = Color.Unspecified ) { val effectiveActiveColor = if (activeColor == Color.Unspecified) { MaterialTheme.colorScheme.primary @@ -1194,16 +1199,8 @@ fun EpubReaderPageSlider( } else { contentColor } - val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) { - MaterialTheme.colorScheme.surfaceVariant - } else { - thumbnailSurfaceColor - } - val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - thumbnailContentColor - } + val maxPage = totalPages.coerceAtLeast(1) + val currentPage = sliderCurrentPage.roundToInt().coerceIn(1, maxPage) AnimatedVisibility( visible = isVisible, @@ -1211,128 +1208,73 @@ fun EpubReaderPageSlider( exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)), modifier = modifier ) { - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(Modifier.height(72.dp)) - Box( + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}, + ) { + Row( modifier = Modifier .fillMaxWidth() - .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}, + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) - .padding(horizontal = 32.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - BoxWithConstraints( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Slider( - value = sliderCurrentPage, - onValueChange = onScrub, - valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)), - steps = if (totalPages > 2) totalPages - 2 else 0, - modifier = Modifier.fillMaxWidth(), - thumb = { - Surface( - modifier = Modifier.size(20.dp), - shape = CircleShape, - color = effectiveActiveColor, - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) {} - }, - track = { sliderState -> - val trackHeight = 2.dp - val trackShape = RoundedCornerShape(trackHeight) - val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start - val fraction = if (range == 0f) 0f else { - ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f) - } - Box( - modifier = Modifier - .fillMaxWidth() - .height(trackHeight) - .background( - color = effectiveInactiveColor, - shape = trackShape - ) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .background( - color = effectiveActiveColor, - shape = trackShape - ) - ) - } - } - ) - - // Thumbnail Indicator - val startPageOffsetFraction = if (totalPages > 1) { - (sliderStartPage - 1).toFloat() / (totalPages - 1) - } else { - 0f - } - val thumbWidth = 20.dp - val trackWidth = maxWidth - thumbWidth - val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2) - val thumbnailModifier = Modifier - .graphicsLayer { clip = false } - .align(Alignment.TopStart) - .offset( - x = startPagePixelPosition - (45.dp / 2), - y = (-72).dp + IconButton( + onClick = { + onJumpToPage( + readerSliderStepPage( + currentPage = currentPage, + delta = -1, + minPage = 1, + maxPage = maxPage ) + ) + }, + enabled = currentPage > 1, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = stringResource(R.string.desktop_previous_page), + tint = effectiveContentColor.copy(alpha = if (currentPage > 1) 0.9f else 0.32f) + ) + } - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { - startPageThumbnail?.let { thumbnail -> - ThumbnailWithIndicator( - modifier = thumbnailModifier, - borderColor = effectiveActiveColor, - onClick = { onJumpToPage(sliderStartPage) } - ) { - Image( - bitmap = thumbnail.asImageBitmap(), - contentDescription = stringResource(R.string.content_desc_start_page_thumbnail), - contentScale = ContentScale.FillBounds, - modifier = Modifier.fillMaxSize() - ) - } - } - } else { - val startPageChapterIndex = remember(sliderStartPage, paginator) { - (paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1) - } - val startPageChapterTitle = remember(startPageChapterIndex) { - startPageChapterIndex?.let { chapters.getOrNull(it)?.title } - } - ThumbnailWithIndicator( - modifier = thumbnailModifier, - borderColor = effectiveActiveColor, - onClick = { onJumpToPage(sliderStartPage) } - ) { - PaginatedThumbnailContent( - pageNumber = sliderStartPage, - chapterTitle = startPageChapterTitle, - surfaceColor = effectiveThumbnailSurfaceColor, - contentColor = effectiveThumbnailContentColor - ) - } - } - } + ReaderMinimalSlider( + value = sliderCurrentPage.coerceIn(1f, maxPage.toFloat()), + onValueChange = onScrub, + valueRange = 1f..maxPage.toFloat(), + enabled = maxPage > 1, + activeColor = effectiveActiveColor, + inactiveColor = effectiveInactiveColor, + thumbColor = effectiveActiveColor, + markerValue = sliderStartPage.toFloat(), + markerColor = effectiveActiveColor, + modifier = Modifier + .weight(1f) + .height(32.dp) + ) - Text( - text = "${sliderCurrentPage.roundToInt()} / $totalPages", - style = MaterialTheme.typography.bodyLarge, - color = effectiveContentColor, - fontSize = 18.sp + IconButton( + onClick = { + onJumpToPage( + readerSliderStepPage( + currentPage = currentPage, + delta = 1, + minPage = 1, + maxPage = maxPage + ) + ) + }, + enabled = currentPage < maxPage, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = stringResource(R.string.desktop_next_page), + tint = effectiveContentColor.copy(alpha = if (currentPage < maxPage) 0.9f else 0.32f) ) } } @@ -1375,109 +1317,6 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { } } -@Composable -internal fun ThumbnailWithIndicator( - modifier: Modifier = Modifier, - borderColor: Color = Color.Unspecified, - onClick: () -> Unit, - content: @Composable () -> Unit -) { - val effectiveBorderColor = if (borderColor == Color.Unspecified) { - MaterialTheme.colorScheme.primary - } else { - borderColor - } - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Surface( - modifier = Modifier - .width(45.dp) - .height(64.dp) - .clickable(onClick = onClick), - shape = RoundedCornerShape(4.dp), - border = BorderStroke(2.dp, effectiveBorderColor) - ) { - content() - } - Box( - modifier = Modifier - .offset(y = (-4).dp) - .size(8.dp) - .rotate(45f) - .background(effectiveBorderColor) - ) - } -} - -@Composable -private fun PaginatedThumbnailContent( - pageNumber: Int, - chapterTitle: String?, - surfaceColor: Color = Color.Unspecified, - contentColor: Color = Color.Unspecified -) { - val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) { - MaterialTheme.colorScheme.surfaceVariant - } else { - surfaceColor - } - val effectiveContentColor = if (contentColor == Color.Unspecified) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - contentColor - } - Surface( - modifier = Modifier.fillMaxSize(), - color = effectiveSurfaceColor, - contentColor = effectiveContentColor - ) { - Column( - modifier = Modifier.padding(4.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (chapterTitle != null) { - Text( - text = chapterTitle, - style = MaterialTheme.typography.labelSmall, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - lineHeight = 10.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - } - Text( - text = "$pageNumber", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold - ) - } - } -} - -suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? { - return withContext(Dispatchers.Main) { - if (webView.width <= 0 || webView.height <= 0) return@withContext null - try { - val thumbnailWidth = 180 - val thumbnailHeight = 256 - val bitmap = createBitmap(thumbnailWidth, thumbnailHeight) - val canvas = Canvas(bitmap) - val scale = thumbnailWidth.toFloat() / webView.width.toFloat() - canvas.scale(scale, scale) - canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat()) - webView.draw(canvas) - bitmap - } catch (e: Exception) { - Timber.e(e, "Failed to capture webview content") - null - } - } -} - @Composable fun SpeedDropdown( label: String, @@ -2204,6 +2043,7 @@ private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) { ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.BOOK_REPLACEMENTS -> Icon(painterResource(id = R.drawable.text_fields), contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp)) @@ -2260,8 +2100,8 @@ fun TtsOverlayControls( ttsController: com.aryan.reader.tts.TtsController, ttsState: TtsState, currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, - isCollapsed: Boolean, - onCollapseChange: (Boolean) -> Unit, + overlaySize: ReaderTtsOverlaySize, + onOverlaySizeChange: (ReaderTtsOverlaySize) -> Unit, onLocateCurrentChunk: () -> Unit, onOpenTtsSettings: () -> Unit, onClose: () -> Unit, @@ -2301,11 +2141,17 @@ fun TtsOverlayControls( } } val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) { - if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) { - "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}" - } else { - null - } + formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks) + } + val miniBarTitle = ttsState.bookTitle + ?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.action_read_aloud) + val miniBarSubtitle = remember(chapterLabel, chunkLabel, progressPercent, miniBarTitle) { + listOfNotNull( + chunkLabel, + progressPercent?.let { "$it%" }, + chapterLabel?.takeIf { it != miniBarTitle } + ).joinToString(" - ") } val canSkipPreviousChunk = !ttsState.isLoading && ttsState.currentChunkIndex > 0 && @@ -2330,24 +2176,40 @@ fun TtsOverlayControls( tonalElevation = 0.dp, shadowElevation = 0.dp, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)), - modifier = modifier.widthIn(max = 400.dp).animateContentSize() + modifier = modifier + .widthIn(max = if (overlaySize == ReaderTtsOverlaySize.MEDIUM) 560.dp else 400.dp) + .animateContentSize() ) { AnimatedContent( - targetState = isCollapsed, + targetState = overlaySize, transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, label = "TtsOverlayUnified" - ) { collapsed -> - if (collapsed) { + ) { size -> + if (size == ReaderTtsOverlaySize.SMALL) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { IconButton( - onClick = { onCollapseChange(false) }, + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) }, modifier = Modifier.size(36.dp) ) { - Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon( + Icons.Default.KeyboardArrowUp, + stringResource(R.string.content_desc_expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.KeyboardArrowLeft, + stringResource(R.string.content_desc_expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) { FilledIconButton( @@ -2371,6 +2233,114 @@ fun TtsOverlayControls( ) } } + } else if (size == ReaderTtsOverlaySize.MEDIUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onLocateCurrentChunk) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = miniBarTitle, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (miniBarSubtitle.isNotBlank()) { + Text( + text = miniBarSubtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(4.dp)) + + IconButton( + enabled = canSkipPreviousChunk, + onClick = { ttsController.skipToPreviousChunk() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = stringResource(R.string.content_desc_tts_previous_chunk), + modifier = Modifier.size(24.dp) + ) + } + + Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() }, + modifier = Modifier.size(44.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + stringResource(R.string.content_desc_play_pause), + modifier = Modifier.size(22.dp) + ) + } + if (ttsState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + strokeWidth = 2.dp + ) + } + } + + IconButton( + enabled = canSkipNextChunk, + onClick = { ttsController.skipToNextChunk() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = stringResource(R.string.content_desc_tts_next_chunk), + modifier = Modifier.size(24.dp) + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { + IconButton( + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) }, + modifier = Modifier.size(34.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.content_desc_expand), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) }, + modifier = Modifier.size(34.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = stringResource(R.string.content_desc_collapse), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } else { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -2378,7 +2348,10 @@ fun TtsOverlayControls( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Surface( color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(8.dp) @@ -2428,6 +2401,8 @@ fun TtsOverlayControls( } } + Spacer(Modifier.width(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) { Icon( @@ -2437,8 +2412,27 @@ fun TtsOverlayControls( tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) { - Icon(Icons.Default.ChevronRight, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + IconButton( + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + stringResource(R.string.content_desc_collapse), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.KeyboardArrowRight, + stringResource(R.string.content_desc_collapse), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) { Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp)) 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 3f8996e..96e7f55 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -31,7 +31,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager -import android.graphics.Bitmap import android.media.AudioManager import android.net.Uri import android.os.Build @@ -155,6 +154,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.util.UnstableApi import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig +import com.aryan.reader.BookWordReplacementsSheet import com.aryan.reader.BuiltInThemes import com.aryan.reader.MainViewModel import com.aryan.reader.R @@ -176,9 +176,11 @@ import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.hasReadableExtractedContent +import com.aryan.reader.epub.plainTextCharacterCount import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency +import com.aryan.reader.loadBookReplacementPreferences import com.aryan.reader.loadReaderBrightnessSettings import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadEpubRightToLeftPagination @@ -195,16 +197,20 @@ import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.ListItemBlock import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.LocatorConverter +import com.aryan.reader.paginatedreader.NativeVerticalLocation +import com.aryan.reader.paginatedreader.NativeVerticalReaderScreen import com.aryan.reader.paginatedreader.PaginatedReaderScreen import com.aryan.reader.paginatedreader.ParagraphBlock import com.aryan.reader.paginatedreader.QuoteBlock import com.aryan.reader.paginatedreader.TextContentBlock import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage 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.saveBookReplacementPreferences import com.aryan.reader.saveReaderBrightnessSettings import com.aryan.reader.saveReaderScreenOrientationMode import com.aryan.reader.saveEpubRightToLeftPagination @@ -212,11 +218,16 @@ import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveReaderSliderToggled import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.shouldRenderReaderSlider +import com.aryan.reader.shared.ReaderBookReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator +import com.aryan.reader.tts.ReaderTtsOverlaySize import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode +import com.aryan.reader.tts.loadReaderTtsOverlaySize +import com.aryan.reader.tts.readerTtsOverlayAlignmentBias +import com.aryan.reader.tts.saveReaderTtsOverlaySize import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.withTtsReplacements import com.aryan.reader.shared.reader.ReaderJumpHistory @@ -238,6 +249,7 @@ import timber.log.Timber import java.io.File import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -553,7 +565,7 @@ fun EpubReaderScreen( coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit, + onImportFonts: (List) -> Unit, viewModel: MainViewModel ) { val uiState by viewModel.uiState.collectAsState() @@ -635,7 +647,7 @@ fun EpubReaderScreen( coverImagePath = coverImagePath, onRenderModeChange = onRenderModeChange, customFonts = customFonts, - onImportFont = onImportFont, + onImportFonts = onImportFonts, onToggleReflow = onOpenOriginal, onDeleteReflow = if (isReflowFile) { { @@ -674,7 +686,7 @@ fun EpubReaderHost( coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit, + onImportFonts: (List) -> Unit, onToggleReflow: ((Int) -> Unit)? = null, onDeleteReflow: (() -> Unit)? = null, stableBookId: String? = null, @@ -718,7 +730,6 @@ fun EpubReaderHost( val scrubDebounceJob = remember { mutableStateOf(null) } val volumeScrollFocusDebounceJob = remember { mutableStateOf(null) } var sliderStartPage by remember { mutableIntStateOf(0) } - var startPageThumbnail by remember { mutableStateOf(null) } var pendingNoteForNewHighlight by remember { mutableStateOf(false) } var highlightToNoteCfi by remember { mutableStateOf(null) } @@ -800,7 +811,7 @@ fun EpubReaderHost( } var isAutoScrollCollapsed by remember { mutableStateOf(false) } - var isTtsCollapsed by remember { mutableStateOf(false) } + var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) } var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } @@ -994,12 +1005,18 @@ fun EpubReaderHost( var showRecapPopup by remember { mutableStateOf(false) } var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) } + var useNativeVerticalRenderer by remember { mutableStateOf(loadNativeVerticalRenderer(context)) } + val isNativeVerticalMode = currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer 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) } var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) } + LaunchedEffect(useNativeVerticalRenderer) { + saveNativeVerticalRenderer(context, useNativeVerticalRenderer) + } + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view) val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp) @@ -1099,7 +1116,7 @@ fun EpubReaderHost( val ttsState by ttsController.ttsState.collectAsState() val totalBookLengthChars = remember(chapters) { - chapters.sumOf { it.plainTextContent.length.toLong() } + chapters.sumOf { it.plainTextCharacterCount().toLong() } } var topVisibleChunkIndex by remember { mutableIntStateOf(0) } @@ -1117,6 +1134,7 @@ fun EpubReaderHost( var imageToLoad by remember { mutableStateOf(null) } var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) } var bookmarkPageMap by remember { mutableStateOf>(emptyMap()) } + var bookmarkLocatorMap by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(Unit) { Timber.tag("POS_DIAG").d("Reader Opening: initialLocator=$initialLocator, initialCfi=$initialCfi") @@ -1140,13 +1158,69 @@ fun EpubReaderHost( var currentScrollHeightValue by remember { mutableIntStateOf(0) } var currentClientHeightValue by remember { mutableIntStateOf(0) } + var nativeVerticalCurrentPage by rememberSaveable(epubBook.title) { mutableIntStateOf(0) } + var nativeVerticalTotalPages by remember { mutableIntStateOf(0) } + var nativeVerticalProgress by remember { mutableFloatStateOf(0f) } + var nativeVerticalLocation by remember { mutableStateOf(null) } + var nativeVerticalScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalLocatorScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalLocatorScrollRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalLocatorScrollKeepVisible by remember { mutableStateOf(false) } + var nativeVerticalProgressScrollRequest by remember { mutableStateOf(null) } + var nativeVerticalProgressScrollRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalScrollDeltaRequest by remember { mutableStateOf(null) } + var nativeVerticalScrollDeltaRequestId by remember { mutableLongStateOf(0L) } + var nativeVerticalScrollDeltaAnimated by remember { mutableStateOf(true) } - val currentBookProgress by remember(currentChapterIndex, currentScrollYPosition, currentScrollHeightValue, currentClientHeightValue, totalBookLengthChars) { + fun currentNativeVerticalLocator(): Locator? { + val bookPaginator = paginator as? BookPaginator + val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage) + return nativeVerticalLocation?.locator + ?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex } + ?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage) + } + + fun requestNativeVerticalLocatorScroll( + locator: Locator?, + fallbackPage: Int? = null, + fallbackChapterIndex: Int? = locator?.chapterIndex, + keepVisible: Boolean = false + ) { + if (locator != null) { + nativeVerticalLocatorScrollRequest = locator + nativeVerticalLocatorScrollRequestId += 1L + nativeVerticalLocatorScrollKeepVisible = keepVisible + lastKnownLocator = locator + currentChapterIndex = locator.chapterIndex + } else if (fallbackPage != null) { + nativeVerticalScrollRequest = fallbackPage + nativeVerticalLocatorScrollKeepVisible = false + fallbackChapterIndex?.let { currentChapterIndex = it } + } + } + + fun requestNativeVerticalProgressScroll(progressPercent: Float) { + nativeVerticalProgressScrollRequest = progressPercent.coerceIn(0f, 100f) + nativeVerticalProgressScrollRequestId += 1L + } + + val currentBookProgress by remember( + currentChapterIndex, + currentScrollYPosition, + currentScrollHeightValue, + currentClientHeightValue, + totalBookLengthChars, + isNativeVerticalMode, + nativeVerticalProgress + ) { derivedStateOf { + if (isNativeVerticalMode) { + return@derivedStateOf nativeVerticalProgress.coerceIn(0f, 100f) + } if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(currentChapterIndex) - .sumOf { it.plainTextContent.length.toLong() } + .sumOf { it.plainTextCharacterCount().toLong() } val progressWithinChapter = if (currentScrollHeightValue > currentClientHeightValue) { @@ -1159,7 +1233,7 @@ fun EpubReaderHost( } val currentChapterLengthChars = - chapters.getOrNull(currentChapterIndex)?.plainTextContent?.length?.toLong() ?: 0L + chapters.getOrNull(currentChapterIndex)?.plainTextCharacterCount()?.toLong() ?: 0L val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() @@ -1244,7 +1318,11 @@ fun EpubReaderHost( } val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) - val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0 + val isEpubSliderReady = when { + isNativeVerticalMode -> nativeVerticalTotalPages > 0 + currentRenderMode == RenderMode.VERTICAL_SCROLL -> true + else -> paginatedPagerState.pageCount > 0 + } val epubSliderChromeVisible = shouldRenderReaderSlider( isToggledOn = isPageSliderVisible, isBottomChromeVisible = showBars, @@ -1259,6 +1337,14 @@ fun EpubReaderHost( var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } + LaunchedEffect(isNativeVerticalMode) { + if (isNativeVerticalMode) { + webViewRefForTts = null + } else { + nativeVerticalLocation = null + } + } + var isMusicianMode by remember { mutableStateOf(loadMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) } @@ -1300,17 +1386,34 @@ fun EpubReaderHost( } } - LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) { - if (isAutoScrollModeActive) { + LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused, isNativeVerticalMode) { + if (isNativeVerticalMode) { + webViewRefForTts?.evaluateJavascript("javascript:window.autoScroll.stop();", null) + } else if (isAutoScrollModeActive) { updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed) } else { webViewRefForTts?.evaluateJavascript("javascript:window.autoScroll.stop();", null) } } + LaunchedEffect(isNativeVerticalMode, isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) { + if (!isNativeVerticalMode) return@LaunchedEffect + while (isActive && isAutoScrollModeActive && isAutoScrollPlaying && !isAutoScrollTempPaused) { + if (nativeVerticalLocation?.isAtEnd == true) { + isAutoScrollPlaying = false + break + } + nativeVerticalScrollDeltaRequestId += 1L + nativeVerticalScrollDeltaAnimated = false + nativeVerticalScrollDeltaRequest = autoScrollSpeed.coerceAtLeast(0f) * 0.5f + delay(16L) + } + } + var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsReplacementsSheet by remember { mutableStateOf(false) } + var showBookReplacementsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) } @@ -1319,6 +1422,14 @@ fun EpubReaderHost( ttsReplacementPreferences = next saveTtsReplacementPreferences(context, next) } + var bookReplacementPreferences by remember { mutableStateOf(loadBookReplacementPreferences(context)) } + val updateBookReplacementPreferences: (ReaderBookReplacementPreferences) -> Unit = { next -> + bookReplacementPreferences = next + saveBookReplacementPreferences(context, next) + } + val bookReplacementSignature = remember(bookReplacementPreferences, bookId) { + bookReplacementPreferences.signatureForFile(bookId) + } var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } @@ -1447,13 +1558,13 @@ fun EpubReaderHost( suspend fun saveResolvedLocatorPosition(locator: Locator, cfiForWebView: String?) { lastKnownLocator = locator - val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextContent?.length?.toLong() ?: 0L + val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextCharacterCount()?.toLong() ?: 0L val exactOffset = locatorConverter.getTextOffset(epubBook, locator)?.coerceAtLeast(0) ?: 0 val boundedOffset = exactOffset.coerceAtMost(chapterLengthChars.toInt()).toLong() val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = - chapters.take(locator.chapterIndex).sumOf { it.plainTextContent.length.toLong() } + chapters.take(locator.chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val totalCharsScrolled = completedCharsInPreviousChapters + boundedOffset val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() @@ -1495,9 +1606,15 @@ fun EpubReaderHost( val chapterIndex = getActiveTtsChapterIndex() ?: return false val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: return false - val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: return false + val sourceOffset = ttsState.currentWordStartOffset.takeIf { it >= 0 } + ?: ttsState.startOffsetInSource.takeIf { it >= 0 } + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) + ?.let { baseLocator -> + sourceOffset?.let { baseLocator.copy(charOffset = it) } ?: baseLocator + } + ?: return false - logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") + logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)} sourceOffset=$sourceOffset") saveResolvedLocatorPosition(locator, sourceCfi) return true } @@ -1519,7 +1636,9 @@ fun EpubReaderHost( val sourceOffset = ttsState.currentWordStartOffset.takeIf { it >= 0 } ?: ttsState.startOffsetInSource.takeIf { it >= 0 } - val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: run { + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi)?.let { baseLocator -> + sourceOffset?.let { baseLocator.copy(charOffset = it) } ?: baseLocator + } ?: run { logTtsChapterDiag("navigateToActiveTtsPosition aborted: locator conversion failed. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") return false } @@ -1530,6 +1649,27 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator ?: run { + logTtsChapterDiag("Native vertical locate aborted: paginator unavailable. reason=$reason") + return false + } + val pageIndex = + bookPaginator.findStablePageForLocator(locator) + ?: bookPaginator.findStableChapterStartPage(chapterIndex) ?: run { + logTtsChapterDiag("Native vertical locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex") + return false + } + logTtsChapterDiag("Native vertical locate scrolling to page=$pageIndex. reason=$reason") + isNavigatingToPosition = true + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = pageIndex, + fallbackChapterIndex = chapterIndex + ) + isNavigatingToPosition = false + return true + } isNavigatingToPosition = true initialScrollTargetForChapter = null isDetachedFromVerticalTts = false @@ -1626,20 +1766,57 @@ fun EpubReaderHost( userStoppedTts = false initiateTtsPlayback( - renderMode = currentRenderMode, - webView = webViewRefForTts, + renderMode = if (isNativeVerticalMode) RenderMode.PAGINATED else currentRenderMode, + webView = if (isNativeVerticalMode) null else webViewRefForTts, onPaginatedStart = { scope.launch { val token = viewModel.getAuthToken() - val currentPage = paginatedPagerState.currentPage - val bookPaginator = paginator as? BookPaginator - val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage) + val bookPaginator = paginator as? BookPaginator ?: return@launch + val nativeStartLocator = if (isNativeVerticalMode) currentNativeVerticalLocator() else null + val currentPage = nativeStartLocator + ?.let { locator -> bookPaginator.findStablePageForLocator(locator) } + ?: if (isNativeVerticalMode) { + nativeVerticalCurrentPage + } else { + paginatedPagerState.currentPage + } + val chapterIndex = nativeStartLocator?.chapterIndex + ?: bookPaginator.findChapterIndexForPage(currentPage) if (chapterIndex != null) { val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val pageInChapter = currentPage - chapterStartPage val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex) - val firstChunkOnPage = if (pageInChapter > 0) { + val firstChunkOnPage = if (nativeStartLocator != null && !allTtsChunks.isNullOrEmpty()) { + val sourceCfi = locatorConverter.getCfiFromLocator(epubBook, nativeStartLocator, bookId) + val target = TtsChunk( + text = "", + sourceCfi = sourceCfi?.substringBefore(':').orEmpty(), + startOffsetInSource = nativeStartLocator.charOffset + ) + val nativeStartChunkIndex = findTtsChunkStartIndex(allTtsChunks, target) + ?: allTtsChunks.indexOfFirst { chunk -> + nativeStartLocator.charOffset >= chunk.startOffsetInSource && + nativeStartLocator.charOffset < chunk.startOffsetInSource + chunk.text.length + }.takeIf { it >= 0 } + val nativeStartChunk = nativeStartChunkIndex?.let { allTtsChunks.getOrNull(it) } + if (nativeStartChunk != null) { + val relativeOffset = nativeStartLocator.charOffset - nativeStartChunk.startOffsetInSource + val safeRelativeOffset = relativeOffset.coerceIn(0, nativeStartChunk.text.length) + if (safeRelativeOffset > 0) { + val slicedText = nativeStartChunk.text.substring(safeRelativeOffset) + nativeStartChunk.copy( + text = slicedText, + startOffsetInSource = nativeStartLocator.charOffset, + spokenText = slicedText + ) + } else { + nativeStartChunk + } + } else { + null + } + } else if (pageInChapter > 0) { bookPaginator.getTtsChunksForChapter( chapterIndex = chapterIndex, startingFromPageInChapter = pageInChapter @@ -1708,7 +1885,11 @@ fun EpubReaderHost( } ) - fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { + fun startTtsFromSelectionPaginated( + baseCfi: String, + startOffset: Int, + chapterIndexOverride: Int? = null + ) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return @@ -1718,7 +1899,11 @@ fun EpubReaderHost( scope.launch { val token = viewModel.getAuthToken() val bookPaginator = paginator as? BookPaginator - val chapterIndex = currentChapterInPaginatedMode ?: return@launch + val chapterIndex = if (isNativeVerticalMode) { + chapterIndexOverride ?: currentChapterIndex + } else { + currentChapterInPaginatedMode ?: return@launch + } val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch val foundIdx = findTtsChunkStartIndex( chunks = chunks, @@ -1800,11 +1985,20 @@ fun EpubReaderHost( Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TTS_CHAPTER_CHANGE, from=$currentChapterIndex, to=$nextIndex") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex") - initialScrollTargetForChapter = ChapterScrollPosition.START - cfiToLoad = null - currentScrollYPosition = 0 - currentScrollHeightValue = 0 - currentChapterIndex = nextIndex + if (isNativeVerticalMode) { + requestNativeVerticalLocatorScroll( + locator = Locator(nextIndex, 0, 0), + fallbackChapterIndex = nextIndex + ) + nativeVerticalProgressScrollRequest = null + webViewRefForTts = null + } else { + initialScrollTargetForChapter = ChapterScrollPosition.START + cfiToLoad = null + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = nextIndex + } }, onToggleTtsStartOnLoad = { shouldStart -> Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart") @@ -1831,9 +2025,51 @@ fun EpubReaderHost( scope = scope ) + LaunchedEffect( + isNativeVerticalMode, + ttsState.currentText, + ttsState.sourceCfi, + ttsState.startOffsetInSource, + ttsState.chapterIndex, + ttsChapterIndex, + isDetachedFromVerticalTts + ) { + if (!isNativeVerticalMode) return@LaunchedEffect + if (isDetachedFromVerticalTts) return@LaunchedEffect + if (!isActiveReaderTtsForCurrentBook()) return@LaunchedEffect + if (ttsState.currentText.isNullOrBlank()) return@LaunchedEffect + + val activeTtsChapterIndex = getActiveTtsChapterIndex() ?: return@LaunchedEffect + val sourceCfi = ttsState.sourceCfi?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect + val sourceOffset = ttsState.startOffsetInSource.takeIf { it >= 0 } ?: return@LaunchedEffect + val baseLocator = locatorConverter.getLocatorFromCfi( + epubBook, + activeTtsChapterIndex, + sourceCfi, + bookId + ) ?: run { + logTtsChapterDiag("Native vertical TTS follow skipped: locator conversion failed. cfi=${sourceCfi.take(48)} offset=$sourceOffset") + return@LaunchedEffect + } + val locator = baseLocator.copy(charOffset = sourceOffset) + val fallbackPage = (paginator as? BookPaginator)?.findStablePageForLocator(locator) + ?: (paginator as? BookPaginator)?.findStableChapterStartPage(activeTtsChapterIndex) + + logTtsChapterDiag( + "Native vertical following TTS chunk. chapter=$activeTtsChapterIndex " + + "block=${locator.blockIndex} offset=${locator.charOffset} cfi=${sourceCfi.take(48)}" + ) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = fallbackPage, + fallbackChapterIndex = activeTtsChapterIndex, + keepVisible = true + ) + } + EpubReaderSearchEffects( searchState = searchState, - webViewRef = webViewRefForTts, + webViewRef = if (isNativeVerticalMode) null else webViewRefForTts, currentChapterIndex = currentChapterIndex, focusRequester = searchFocusRequester ) @@ -1876,7 +2112,11 @@ fun EpubReaderHost( fun currentEpubSliderPage(): Int { return when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> currentPageInChapter + RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) { + (nativeVerticalCurrentPage + 1).coerceAtLeast(1) + } else { + currentPageInChapter + } RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1) } } @@ -1913,21 +2153,11 @@ fun EpubReaderHost( } } - LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) { + LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, nativeVerticalCurrentPage, paginatedPagerState.currentPage, isFastScrubbing) { if (isPageSliderVisible && !epubSliderChromeVisible) { resetEpubSliderBookmark() - } - } - - LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) { - if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) { - startPageThumbnail?.recycle() - startPageThumbnail = webViewRefForTts?.let { webView -> - captureWebViewVisibleArea(webView) - } - } else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) { - startPageThumbnail?.recycle() - startPageThumbnail = null + } else if (epubSliderChromeVisible && !isFastScrubbing) { + sliderCurrentPage = currentEpubSliderPage().toFloat() } } @@ -2115,8 +2345,6 @@ fun EpubReaderHost( chapterChunks = emptyList() chapterChunkElementStartIndices = emptyList() chapterChunkElementCounts = emptyList() - startPageThumbnail?.recycle() - startPageThumbnail = null autoScrollResumeJob.value?.cancel() autoScrollResumeJob.value = null } @@ -2175,7 +2403,7 @@ fun EpubReaderHost( } } - LaunchedEffect(currentChapterIndex) { + LaunchedEffect(currentChapterIndex, bookReplacementSignature) { isChapterParsing = true isChapterReadyForBookmarkCheck = false activeFragmentId = null @@ -2187,7 +2415,9 @@ fun EpubReaderHost( chunkTargetOverride = chunkTargetOverride, isInitialCfiLoad = isInitialCfiLoad, cfiToLoad = cfiToLoad, - locatorConverter = locatorConverter + locatorConverter = locatorConverter, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId ) chapterHead = result.head @@ -2256,6 +2486,7 @@ fun EpubReaderHost( @Suppress("SENSELESS_COMPARISON") if (pageToScrollTo != null) { Timber.d("Scrolling to page: $pageToScrollTo") + delay(16) paginatedPagerState.scrollToPage(pageToScrollTo) } else { Timber.w("Could not determine a page to scroll to.") @@ -2298,7 +2529,7 @@ fun EpubReaderHost( lastKnownLocator = locator val bookPaginator = paginator as? BookPaginator val progress = if (totalBookLengthChars > 0 && bookPaginator != null) { - val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter @@ -2321,10 +2552,18 @@ fun EpubReaderHost( val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding - val isPageInfoVisible = when (pageInfoMode) { - PageInfoMode.DEFAULT -> !showBars - PageInfoMode.SYNC -> showBars - PageInfoMode.HIDDEN -> false + val isPageInfoVisible = shouldShowEpubPageInfoBar( + pageInfoMode = pageInfoMode, + showReaderChrome = showBars + ) + + fun androidLocatorCfiToLocator(cfi: String): 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 + ) } LaunchedEffect(bookmarks, paginator) { @@ -2335,20 +2574,25 @@ fun EpubReaderHost( return@LaunchedEffect } Timber.d("Paginator or bookmarks changed. Re-calculating bookmark page map for ${bookmarks.size} bookmarks.") - val newMap = bookmarkPageMap.toMutableMap() + val activeBookmarkCfis = bookmarks.map { it.cfi }.toSet() + val newMap = bookmarkPageMap.filterKeys { it in activeBookmarkCfis }.toMutableMap() + val newLocatorMap = bookmarkLocatorMap.filterKeys { it in activeBookmarkCfis }.toMutableMap() bookmarks.forEach { bookmark -> - if (newMap.containsKey(bookmark.cfi)) return@forEach + if (newMap.containsKey(bookmark.cfi) && newLocatorMap.containsKey(bookmark.cfi)) return@forEach scope.launch { - val locator = locatorConverter.getLocatorFromCfi( - book = epubBook, - chapterIndex = bookmark.chapterIndex, - cfi = bookmark.cfi - ) + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + book = epubBook, + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi + ) if (locator != null) { Timber.d("Bookmark map: Converted CFI '${bookmark.cfi}' to Locator: $locator") + newLocatorMap[bookmark.cfi] = locator + bookmarkLocatorMap = newLocatorMap.toMap() val pageIndex = bookPaginator.findPageForLocator(locator) if (pageIndex != null) { Timber.d("Bookmark map: Found page $pageIndex for locator.") @@ -2362,6 +2606,8 @@ fun EpubReaderHost( } } } + bookmarkPageMap = newMap.toMap() + bookmarkLocatorMap = newLocatorMap.toMap() } LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) { @@ -2396,6 +2642,58 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + scope.launch { + val pageToSave = nativeVerticalCurrentPage + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + val chapterIndex = locator?.chapterIndex ?: bookPaginator?.findChapterIndexForPage(pageToSave) + + if (locator != null) { + val progress = nativeVerticalLocation?.progressPercent ?: if (chapterIndex == null || bookPaginator == null) { + saveResolvedLocatorPosition(locator, null) + onNavigateBack() + return@launch + } else if (totalBookLengthChars > 0) { + val completedCharsInPreviousChapters = + chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } + val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 + val currentPageInChapter = pageToSave - chapterStartPage + val pageCharsScrolledInCurrentChapter = + bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) + val chapterChars = + chapters.getOrNull(chapterIndex)?.plainTextCharacterCount()?.toLong() + ?: Long.MAX_VALUE + val locatorCharsScrolledInCurrentChapter = locator + .takeIf { it.chapterIndex == chapterIndex } + ?.charOffset + ?.toLong() + ?.coerceAtLeast(0L) + ?.coerceAtMost(chapterChars) + val charsScrolledInCurrentChapter = + locatorCharsScrolledInCurrentChapter + ?.coerceAtLeast(pageCharsScrolledInCurrentChapter) + ?: pageCharsScrolledInCurrentChapter + val totalCharsScrolled = + completedCharsInPreviousChapters + charsScrolledInCurrentChapter + val calculatedProgress = + ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() + val isLastPageOfBook = pageToSave == nativeVerticalTotalPages - 1 + if (isLastPageOfBook) 100f else calculatedProgress + } else { + nativeVerticalProgress + } + + Timber.d("Final save for native vertical view. Page: $pageToSave, Locator: $locator, Progress: $progress%") + onSavePosition(locator, null, progress) + } else { + Timber.w("Final save for native vertical view failed. Locator is null.") + } + isSavingAndExiting = false + onNavigateBack() + } + return + } webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null @@ -2420,7 +2718,7 @@ fun EpubReaderHost( onNavigateBack() return@launch } else if (totalBookLengthChars > 0 && bookPaginator != null) { - val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter @@ -2459,11 +2757,23 @@ fun EpubReaderHost( return SharedReaderLocator( chapterIndex = chapterIndex, pageIndex = pageIndex, + blockIndex = blockIndex, + charOffset = charOffset, cfi = cfiOverride ?: "android-locator:$chapterIndex:$blockIndex:$charOffset" ) } fun SharedReaderLocator.toAndroidLocatorOrNull(): Locator? { + val chapter = chapterIndex + val block = blockIndex + val offset = charOffset + if (chapter != null && block != null && offset != null) { + return Locator( + chapterIndex = chapter, + blockIndex = block, + charOffset = offset + ) + } val parts = cfi ?.takeIf { it.startsWith("android-locator:") } ?.split(':') @@ -2477,10 +2787,18 @@ fun EpubReaderHost( fun currentEpubJumpLocator(): SharedReaderLocator? { return when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> SharedReaderLocator( - chapterIndex = currentChapterIndex, - cfi = "android-scroll:$currentScrollYPosition" - ) + RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val pageIndex = nativeVerticalLocation?.compatPageIndex ?: nativeVerticalCurrentPage.takeIf { it >= 0 } + val locator = currentNativeVerticalLocator() + locator?.toEpubJumpLocator(pageIndex = pageIndex) + } else { + SharedReaderLocator( + chapterIndex = currentChapterIndex, + cfi = "android-scroll:$currentScrollYPosition" + ) + } + } RenderMode.PAGINATED -> { val pageIndex = paginatedPagerState.currentPage.takeIf { it >= 0 } val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) @@ -2687,6 +3005,23 @@ fun EpubReaderHost( scope.launch { recordEpubJump(chapterStartJumpLocator(image.chapterIndex)) clearPendingTtsRelocationState("sidebar_image_vertical") + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val imagePage = bookPaginator?.findStablePageForImageSource( + chapterIndex = image.chapterIndex, + sourcePath = image.sourcePath, + elementId = image.elementId, + ordinalInChapter = image.ordinalInChapter + ) + val targetPage = imagePage?.first + ?: bookPaginator?.findStableChapterStartPage(image.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = imagePage?.second, + fallbackPage = targetPage, + fallbackChapterIndex = image.chapterIndex + ) + return@launch + } imageToLoad = image cfiToLoad = null fragmentToLoad = null @@ -2709,6 +3044,17 @@ fun EpubReaderHost( fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { scope.launch { val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = chapterIndex + ) + return@launch + } val targetChunk = locator?.let { it.blockIndex / 20 } cfiToLoad = cfi initialScrollTargetForChapter = null @@ -2736,6 +3082,37 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { clearPendingTtsRelocationState("epub_jump_history") + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val directPage = locator.pageIndex?.takeIf { + nativeVerticalTotalPages <= 0 || it in 0 until nativeVerticalTotalPages + } + val targetLocator = when { + cfi.startsWith("android-locator:") -> locator.toAndroidLocatorOrNull() + cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null -> { + locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) + } + cfi.startsWith("android-search:") && chapterIndex != null -> { + val targetChunk = cfi.split(':').getOrNull(1)?.toIntOrNull() ?: 0 + Locator(chapterIndex, targetChunk.coerceAtLeast(0) * 20, 0) + } + cfi.startsWith("android-fragment:") && chapterIndex != null -> { + val fragment = cfi.substringAfter("android-fragment:") + bookPaginator?.findStableLocatorForAnchor(chapterIndex, fragment) + } + else -> null + } + val targetPage = targetLocator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: directPage + ?: chapterIndex?.let { bookPaginator?.findStableChapterStartPage(it) } + requestNativeVerticalLocatorScroll( + locator = targetLocator, + fallbackPage = targetPage, + fallbackChapterIndex = chapterIndex + ) + if (showBars) showBars = false + return@launch + } when { cfi.startsWith("android-scroll:") -> { val scrollY = cfi.substringAfter("android-scroll:").toIntOrNull() ?: 0 @@ -2882,6 +3259,39 @@ fun EpubReaderHost( Timber.tag("NavDiag").d("navigateToSearchResult index: $index") val targetResult = searchState.searchResults.getOrNull(index) if (targetResult != null && currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + scope.launch { + searchState.currentSearchResultIndex = index + val bookPaginator = paginator as? BookPaginator ?: return@launch + val exactLocator = bookPaginator.findStableLocatorForSearchResult(targetResult) + val pageIdx = exactLocator?.let { bookPaginator.findStablePageForLocator(it) } + ?: bookPaginator.findStablePageForSearchResult(targetResult) + ?: bookPaginator.findStablePageForLocator( + Locator( + targetResult.locationInSource, + targetResult.chunkIndex.coerceAtLeast(0) * 20, + 0 + ) + ) + ?: bookPaginator.findStableChapterStartPage(targetResult.locationInSource) + ?: return@launch + val scrollLocator = exactLocator + ?: bookPaginator.getLocatorForPage(pageIdx) + ?: Locator(targetResult.locationInSource, targetResult.chunkIndex.coerceAtLeast(0) * 20, 0) + recordEpubJump( + scrollLocator.toEpubJumpLocator(pageIndex = pageIdx) + .copy(textQuote = targetResult.snippet.text) + ) + requestNativeVerticalLocatorScroll( + locator = scrollLocator, + fallbackPage = pageIdx, + fallbackChapterIndex = targetResult.locationInSource + ) + searchHighlightTarget = targetResult + if (showBars) showBars = false + } + return + } recordEpubJump( SharedReaderLocator( chapterIndex = targetResult.locationInSource, @@ -3059,6 +3469,32 @@ fun EpubReaderHost( if (targetChapterIndex != -1) { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetLocator = bookPaginator?.findStableLocatorForAnchor( + targetChapterIndex, + entry.fragmentId + ) + val targetPage = targetLocator?.let { bookPaginator.findStablePageForLocator(it) } + ?: bookPaginator?.findStablePageForAnchor( + targetChapterIndex, + entry.fragmentId + ) + ?: bookPaginator?.findStableChapterStartPage(targetChapterIndex) + if (targetPage != null) { + recordEpubJump( + fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath) + .copy(pageIndex = targetPage) + ) + requestNativeVerticalLocatorScroll( + locator = targetLocator ?: bookPaginator?.getLocatorForPage(targetPage), + fallbackPage = targetPage, + fallbackChapterIndex = targetChapterIndex + ) + } + if (showBars) showBars = false + return@launch + } recordEpubJump(fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath)) clearPendingTtsRelocationState("toc_entry_vertical") fragmentToLoad = entry.fragmentId @@ -3185,6 +3621,20 @@ fun EpubReaderHost( drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val targetPage = bookPaginator?.findStableChapterStartPage(index) + if (targetPage != null) { + recordEpubJump(chapterStartJumpLocator(index).copy(pageIndex = targetPage)) + requestNativeVerticalLocatorScroll( + locator = bookPaginator.getLocatorForPage(targetPage) ?: Locator(index, 0, 0), + fallbackPage = targetPage, + fallbackChapterIndex = index + ) + if (showBars) showBars = false + } + return@launch + } if (index != currentChapterIndex) { recordEpubJump(chapterStartJumpLocator(index)) clearPendingTtsRelocationState("sidebar_chapter_vertical") @@ -3230,6 +3680,24 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) + val bookPaginator = paginator as? BookPaginator + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + epubBook, + bookmark.chapterIndex, + bookmark.cfi + ) + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(bookmark.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = bookmark.chapterIndex + ) + return@launch + } recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi @@ -3306,11 +3774,12 @@ fun EpubReaderHost( isNavigatingToPosition = true try { val bookPaginator = paginator as? BookPaginator - val locator = locatorConverter.getLocatorFromCfi( - book = epubBook, - chapterIndex = bookmark.chapterIndex, - cfi = bookmark.cfi - ) + val locator = androidLocatorCfiToLocator(bookmark.cfi) + ?: locatorConverter.getLocatorFromCfi( + book = epubBook, + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi + ) if (locator != null && bookPaginator != null) { Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator") @@ -3347,6 +3816,19 @@ fun EpubReaderHost( drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) + val bookPaginator = paginator as? BookPaginator + val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) + val targetPage = locator?.let { bookPaginator?.findStablePageForLocator(it) } + ?: bookPaginator?.findStableChapterStartPage(highlight.chapterIndex) + requestNativeVerticalLocatorScroll( + locator = locator, + fallbackPage = targetPage, + fallbackChapterIndex = highlight.chapterIndex + ) + return@launch + } recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) cfiToLoad = highlight.cfi val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) @@ -3439,6 +3921,8 @@ fun EpubReaderHost( }, onDeleteBookmark = { bookmarkToDelete -> bookmarks = bookmarks - bookmarkToDelete + bookmarkPageMap = bookmarkPageMap - bookmarkToDelete.cfi + bookmarkLocatorMap = bookmarkLocatorMap - bookmarkToDelete.cfi }, onRenameBookmark = { bookmark, newLabel -> bookmarks = bookmarks.map { @@ -3497,6 +3981,89 @@ fun EpubReaderHost( } } + fun generateSummaryFromPlainChapter(chapterIndex: Int?, force: Boolean) { + scope.launch { + val resolvedChapterIndex = chapterIndex + if (resolvedChapterIndex == null) { + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter)) + isSummarizationLoading = false + return@launch + } + + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + resolvedChapterIndex + ) else null + if (cached != null) { + summarizationResult = SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + return@launch + } + + val token = viewModel.getAuthToken() + val text = paginator?.getPlainTextForChapter(resolvedChapterIndex) + if (!text.isNullOrBlank()) { + var currentCost: Double? = null + var currentFreeRemaining: Int? = null + val finalSummaryBuilder = StringBuilder() + summarizeBookContent( + content = text, + context = context, + authToken = token, + onUsageReceived = { cost, freeRemaining -> + currentCost = cost + currentFreeRemaining = freeRemaining + summarizationResult = summarizationResult?.copy( + cost = cost, + freeRemaining = freeRemaining + ) ?: SummarizationResult( + cost = cost, + freeRemaining = freeRemaining + ) + }, + onUpdate = { chunk -> + finalSummaryBuilder.append(chunk) + val currentSummary = summarizationResult?.summary ?: "" + summarizationResult = SummarizationResult( + summary = currentSummary + chunk, + cost = currentCost, + freeRemaining = currentFreeRemaining + ) + }, + onError = { error -> + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiHubSheet = false + isSummarizationLoading = false + } else { + summarizationResult = SummarizationResult(error = error) + } + }, + onFinish = { + isSummarizationLoading = false + val fullSummary = finalSummaryBuilder.toString() + if (fullSummary.isNotBlank()) { + val chapterTitle = + chapters.getOrNull(resolvedChapterIndex)?.title + ?: context.getString(R.string.chapter_number_format, resolvedChapterIndex + 1) + summaryCacheManager.saveSummary( + epubBook.title, + resolvedChapterIndex, + chapterTitle, + fullSummary + ) + } + } + ) + } else { + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content)) + isSummarizationLoading = false + } + } + } + val handleGenerateSummary: (Boolean) -> Unit = { force -> if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) { showInsufficientCreditsDialog = true @@ -3507,21 +4074,28 @@ fun EpubReaderHost( summarizationResult = null when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - val cached = if (!force) summaryCacheManager.getSummary( - epubBook.title, - currentChapterIndex - ) else null - if (cached != null) { - summarizationResult = - SummarizationResult(summary = cached, isCacheHit = true) - isSummarizationLoading = false + if (isNativeVerticalMode) { + generateSummaryFromPlainChapter( + currentNativeVerticalLocator()?.chapterIndex ?: currentChapterIndex, + force + ) } else { - webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> - Timber.d("JS summarization request: $result") - } ?: run { - isSummarizationLoading = false + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + currentChapterIndex + ) else null + if (cached != null) { summarizationResult = - SummarizationResult(error = context.getString(R.string.error_webview_not_available)) + SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + } else { + webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> + Timber.d("JS summarization request: $result") + } ?: run { + isSummarizationLoading = false + summarizationResult = + SummarizationResult(error = context.getString(R.string.error_webview_not_available)) + } } } } @@ -3625,11 +4199,31 @@ fun EpubReaderHost( showAiHubSheet = true when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - isRequestingRecapCfi = true - webViewRefForTts?.evaluateJavascript( - "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", - null - ) + if (isNativeVerticalMode) { + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + val chapterIndex = locator?.chapterIndex ?: currentChapterIndex + if (bookPaginator != null) { + val charsScrolled = locator?.charOffset?.coerceAtLeast(0) + ?: run { + val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 + val currentPageInChapter = nativeVerticalCurrentPage - startPage + bookPaginator.getCharactersScrolledInChapter( + chapterIndex, + currentPageInChapter + ).toInt() + } + runRecap(chapterIndex, charsScrolled) + } else { + showBanner("Wait for book to load fully.", isError = true) + } + } else { + isRequestingRecapCfi = true + webViewRefForTts?.evaluateJavascript( + "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", + null + ) + } } RenderMode.PAGINATED -> { @@ -3706,18 +4300,39 @@ fun EpubReaderHost( currentChapterIndex = currentChapterIndex, totalChapters = chapters.size, onScrollBy = { amount -> - webViewRefForTts?.evaluateJavascript( - "window.scrollBy({ top: $amount, behavior: 'smooth' });", - null - ) + if (isNativeVerticalMode) { + nativeVerticalScrollDeltaRequestId += 1L + nativeVerticalScrollDeltaAnimated = false + nativeVerticalScrollDeltaRequest = amount.toFloat() + } else { + webViewRefForTts?.evaluateJavascript( + "window.scrollBy({ top: $amount, behavior: 'smooth' });", + null + ) + } }, onNavigateChapter = { offset, target -> scope.launch { clearPendingTtsRelocationState("manual_chapter_change") - initialScrollTargetForChapter = target - currentScrollYPosition = 0 - currentScrollHeightValue = 0 - currentChapterIndex += offset + if (isNativeVerticalMode) { + if (chapters.isNotEmpty()) { + val targetChapter = (currentChapterIndex + offset).coerceIn(0, chapters.lastIndex) + val targetPage = (paginator as? BookPaginator) + ?.findStableChapterStartPage(targetChapter) + if (targetPage != null) { + requestNativeVerticalLocatorScroll( + locator = Locator(targetChapter, 0, 0), + fallbackPage = targetPage, + fallbackChapterIndex = targetChapter + ) + } + } + } else { + initialScrollTargetForChapter = target + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex += offset + } logTtsChapterDiag( "Manual vertical chapter switch via volume/button nav. " + "offset=$offset target=$target newChapter=$currentChapterIndex" @@ -3751,7 +4366,7 @@ fun EpubReaderHost( ) { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp + val pageInfoReserve = if (isPageInfoVisible) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp @@ -3769,6 +4384,166 @@ fun EpubReaderHost( ) { Text(stringResource(R.string.no_chapters_available)) } + } else if (isNativeVerticalMode) { + LaunchedEffect(currentChapterIndex, isNativeVerticalMode) { + webViewRefForTts = null + isChapterParsing = false + isChapterReadyForBookmarkCheck = true + } + NativeVerticalReaderScreen( + book = epubBook, + bookId = readerCacheBookId, + isDarkTheme = isDarkTheme, + effectiveBg = effectiveBg, + effectiveText = effectiveText, + searchQuery = searchState.searchQuery, + fontSizeMultiplier = currentFontSizeEm, + lineHeightMultiplier = currentLineHeight, + paragraphGapMultiplier = currentParagraphGap, + imageSizeMultiplier = currentImageSize, + horizontalMarginMultiplier = currentHorizontalMargin, + verticalMarginMultiplier = currentVerticalMargin, + fontFamily = activeFontFamily, + textAlign = currentTextAlign, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId, + activeHighlightPalette = currentHighlightPalette, + onUpdatePalette = onUpdateHighlightPalette, + ttsHighlightInfo = TtsHighlightInfo( + text = ttsState.currentText ?: "", + cfi = ttsState.sourceCfi ?: "", + offset = ttsState.startOffsetInSource + ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, + activeTextureId = activeTextureId, + activeTextureAlpha = activeTextureAlpha, + initialLocator = lastKnownLocator, + initialPageIndexInBook = nativeVerticalCurrentPage, + scrollRequestPage = nativeVerticalScrollRequest, + scrollRequestLocator = nativeVerticalLocatorScrollRequest, + scrollRequestLocatorId = nativeVerticalLocatorScrollRequestId, + scrollRequestLocatorKeepVisible = nativeVerticalLocatorScrollKeepVisible, + scrollRequestProgressPercent = nativeVerticalProgressScrollRequest, + scrollRequestProgressId = nativeVerticalProgressScrollRequestId, + scrollDeltaRequest = nativeVerticalScrollDeltaRequest, + scrollDeltaRequestId = nativeVerticalScrollDeltaRequestId, + scrollDeltaRequestAnimated = nativeVerticalScrollDeltaAnimated, + onScrollRequestConsumed = { nativeVerticalScrollRequest = null }, + onScrollLocatorRequestConsumed = { + nativeVerticalLocatorScrollRequest = null + nativeVerticalLocatorScrollKeepVisible = false + }, + onScrollProgressRequestConsumed = { nativeVerticalProgressScrollRequest = null }, + onScrollDeltaConsumed = { nativeVerticalScrollDeltaRequest = null }, + modifier = Modifier.fillMaxSize(), + onPaginatorReady = { newPaginator -> + paginator = newPaginator + }, + onVisiblePageChanged = { pageIndex, chapterIndex, locator -> + nativeVerticalCurrentPage = pageIndex + if (chapterIndex != null) { + currentChapterIndex = chapterIndex + } + if (locator != null) { + lastKnownLocator = locator + } + currentScrollYPosition = pageIndex + currentClientHeightValue = 1 + currentScrollHeightValue = nativeVerticalTotalPages.coerceAtLeast(1) + }, + onProgressChanged = { pageIndex, totalPages, progressPercent -> + nativeVerticalCurrentPage = pageIndex + nativeVerticalTotalPages = totalPages + nativeVerticalProgress = progressPercent.coerceIn(0f, 100f) + currentScrollYPosition = pageIndex + currentClientHeightValue = 1 + currentScrollHeightValue = totalPages.coerceAtLeast(1) + }, + onLocationChanged = { location -> + nativeVerticalLocation = location + }, + onTap = { + focusManager.clearFocus() + if (volumeScrollEnabled && !searchState.isSearchActive) { + containerFocusRequester.requestFocus() + } + if (showBars || showFormatAdjustmentBars) { + showBars = false + showFormatAdjustmentBars = false + } else { + showBars = true + } + }, + isProUser = isProUser, + isOss = BuildConfig.FLAVOR == "oss", + onShowDictionaryUpsellDialog = { + showDictionaryUpsellDialog = true + }, + onWordSelectedForAiDefinition = { text -> + onDictionaryLookup(text) + }, + onTranslate = { text -> + onTranslateLookup(text) + }, + onSearch = { text -> + onSearchLookup(text) + }, + onStartTtsFromSelection = { cfi, offset, chapterIndex -> + startTtsFromSelectionPaginated(cfi, offset, chapterIndex) + }, + userHighlights = userHighlights.filter { highlight -> + highlight.chapterIndex in (currentChapterIndex - 1)..(currentChapterIndex + 1) + }, + onHighlightCreated = { cfi, text, colorId, locator -> + val chapterIndex = locator.chapterIndex ?: currentChapterIndex + val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW + val finalCfi = processAndAddHighlight( + newCfi = cfi, + newText = text, + newColor = color, + chapterIndex = chapterIndex, + currentList = userHighlights, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ) + if (pendingNoteForNewHighlight) { + pendingNoteForNewHighlight = false + highlightToNoteCfi = finalCfi + } + }, + onNoteRequested = { cfi -> + if (cfi != null) { + highlightToNoteCfi = cfi + } else { + pendingNoteForNewHighlight = true + } + }, + onFootnoteRequested = { html -> + activeFootnoteHtml = html + }, + onInternalLinkNavigated = { targetPageIndex, targetLocatorFromLink -> + val bookPaginator = paginator as? BookPaginator + val targetChapter = targetLocatorFromLink?.chapterIndex + ?: bookPaginator?.findChapterIndexForPage(targetPageIndex) + val targetLocator = targetLocatorFromLink ?: bookPaginator?.getLocatorForPage(targetPageIndex) + if (targetChapter != null) { + currentChapterIndex = targetChapter + } + if (targetLocator != null) { + lastKnownLocator = targetLocator + } + paginatedJumpLocatorForPage( + pageIndex = targetPageIndex, + targetLocator = targetLocator, + fallbackChapterIndex = targetChapter + )?.let { recordEpubJump(it) } + }, + onHighlightDeleted = { cfi -> + userHighlights.find { it.cfi == cfi }?.let { userHighlights.remove(it) } + } + ) } else { AnimatedContent( targetState = currentChapterIndex, @@ -3844,9 +4619,10 @@ fun EpubReaderHost( val chapterKeyForWebView = remember( chapterToRender.htmlFilePath, - epubBook.extractionBasePath + epubBook.extractionBasePath, + bookReplacementSignature ) { - "${epubBook.extractionBasePath}/${chapterToRender.htmlFilePath}" + "${epubBook.extractionBasePath}/${chapterToRender.htmlFilePath}?bookReplacements=${bookReplacementSignature.hashCode()}" } val chapterDirectoryPath = @@ -3883,7 +4659,7 @@ fun EpubReaderHost( .indexOf(target) .coerceAtLeast(0) - val js = "javascript:console.log('NavDiag: Executing robust search highlight JS'); window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" + val js = "javascript:window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" Timber.tag("NavDiag").d("Executing search highlight/scroll JS: $js") webView.evaluateJavascript(js) { result -> Timber.tag("NavDiag").d("JS highlight/scroll result: $result") @@ -4574,7 +5350,7 @@ fun EpubReaderHost( val currentChapterLengthChars = chapters.getOrNull( latestChapterIndex - )?.plainTextContent?.length?.toLong() + )?.plainTextCharacterCount()?.toLong() ?: 0L // Handle Recap Request INTERCEPTION @@ -4604,7 +5380,7 @@ fun EpubReaderHost( val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(latestChapterIndex) - .sumOf { it.plainTextContent.length.toLong() } + .sumOf { it.plainTextCharacterCount().toLong() } val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() @@ -4752,7 +5528,7 @@ fun EpubReaderHost( } RenderMode.PAGINATED -> { - val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp + val pageInfoReserve = if (isPageInfoVisible) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp @@ -4780,6 +5556,8 @@ fun EpubReaderHost( verticalMarginMultiplier = currentVerticalMargin, fontFamily = activeFontFamily, textAlign = currentTextAlign, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookId, activeHighlightPalette = currentHighlightPalette, onUpdatePalette = onUpdateHighlightPalette, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, @@ -4894,8 +5672,8 @@ fun EpubReaderHost( val currentChapter = currentChapterInPaginatedMode ?: return@filter false highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1) }, - onHighlightCreated = { cfi, text, colorId -> - val chapterIndex = currentChapterInPaginatedMode ?: 0 + onHighlightCreated = { cfi, text, colorId, locator -> + val chapterIndex = locator.chapterIndex ?: currentChapterInPaginatedMode ?: 0 Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " + "existingCount=${userHighlights.size} textLen=${text.length} " + @@ -4908,7 +5686,12 @@ fun EpubReaderHost( newText = text, newColor = color, chapterIndex = chapterIndex, - currentList = userHighlights + currentList = userHighlights, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) ) val savedHighlight = userHighlights.find { it.chapterIndex == chapterIndex && it.cfi == finalCfi @@ -4937,10 +5720,11 @@ fun EpubReaderHost( onFootnoteRequested = { html -> activeFootnoteHtml = html }, - onInternalLinkNavigated = { targetPageIndex -> + onInternalLinkNavigated = { targetPageIndex, targetLocatorFromLink -> val bookPaginator = paginator as? BookPaginator - val targetChapter = bookPaginator?.findChapterIndexForPage(targetPageIndex) - val targetLocator = bookPaginator?.getLocatorForPage(targetPageIndex) + val targetChapter = targetLocatorFromLink?.chapterIndex + ?: bookPaginator?.findChapterIndexForPage(targetPageIndex) + val targetLocator = targetLocatorFromLink ?: bookPaginator?.getLocatorForPage(targetPageIndex) val navigationEpoch = System.currentTimeMillis() paginatedExplicitNavigationEpoch = navigationEpoch paginatedExplicitNavigationAnchor = targetLocator @@ -4996,6 +5780,99 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + if (isNativeVerticalMode) { + val currentNativeLocator = currentNativeVerticalLocator() + val bookmarkedOnPage = remember( + currentNativeLocator, + nativeVerticalLocation?.visibleTextRanges, + nativeVerticalCurrentPage, + bookmarkLocatorMap, + bookmarkPageMap, + bookmarks + ) { + val visibleRanges = nativeVerticalLocation?.visibleTextRanges.orEmpty() + val visibleRangeBookmark = bookmarks.find { bookmark -> + val bookmarkLocator = bookmarkLocatorMap[bookmark.cfi] ?: return@find false + visibleRanges.any { range -> + range.chapterIndex == bookmarkLocator.chapterIndex && + range.blockIndex == bookmarkLocator.blockIndex && + bookmarkLocator.charOffset in range.startCharOffset..range.endCharOffset + } + } + if (visibleRangeBookmark != null) return@remember visibleRangeBookmark + + val locator = currentNativeLocator + val locatorBookmark = if (locator != null) { + bookmarks.find { bookmark -> + val bookmarkLocator = bookmarkLocatorMap[bookmark.cfi] + bookmarkLocator != null && + bookmarkLocator.chapterIndex == locator.chapterIndex && + bookmarkLocator.blockIndex == locator.blockIndex && + abs(bookmarkLocator.charOffset - locator.charOffset) <= 160 + } + } else { + null + } + locatorBookmark ?: bookmarks.find { bookmark -> + bookmarkPageMap[bookmark.cfi] == nativeVerticalCurrentPage + } + } + + isBookmarked = bookmarkedOnPage != null + onBookmarkClick = { + if (isBookmarked) { + bookmarkedOnPage?.let { bookmarkToRemove -> + bookmarks = bookmarks - bookmarkToRemove + bookmarkPageMap = bookmarkPageMap - bookmarkToRemove.cfi + bookmarkLocatorMap = bookmarkLocatorMap - bookmarkToRemove.cfi + Timber.d("Native vertical click: Removing bookmark: $bookmarkToRemove") + } + } else { + val bookPaginator = paginator as? BookPaginator + val locator = currentNativeVerticalLocator() + if (locator != null && bookPaginator != null) { + scope.launch { + val finalCfi = locatorConverter.getCfiFromLocator( + epubBook, + locator + ) ?: "android-locator:${locator.chapterIndex}:${locator.blockIndex}:${locator.charOffset}" + val pageContent = bookPaginator.getPageContent(nativeVerticalCurrentPage) + val targetBlockForBookmark = + pageContent?.content?.firstOrNull { + it is TextContentBlock && it.blockIndex == locator.blockIndex && it.cfi != null + } + ?: pageContent?.content?.firstOrNull { it.blockIndex == locator.blockIndex && it.cfi != null } + ?: pageContent?.content?.firstOrNull { it is TextContentBlock && it.cfi != null } + ?: pageContent?.content?.firstOrNull { it.cfi != null } + val chapterTitle = + epubBook.chapters.getOrNull(locator.chapterIndex)?.title + ?: context.getString(R.string.unknown_chapter) + val snippet = + (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) + ?: chapterTitle + val chapterStartPage = bookPaginator.chapterStartPageIndices[locator.chapterIndex] + val totalPages = bookPaginator.chapterPageCounts[locator.chapterIndex] + val pageInChapter = chapterStartPage?.let { + nativeVerticalCurrentPage - it + 1 + } + val newBookmark = Bookmark( + cfi = finalCfi, + chapterTitle = chapterTitle, + label = null, + snippet = snippet, + pageInChapter = pageInChapter, + totalPagesInChapter = totalPages, + chapterIndex = locator.chapterIndex + ) + bookmarks = bookmarks + newBookmark + bookmarkPageMap = bookmarkPageMap + (finalCfi to nativeVerticalCurrentPage) + bookmarkLocatorMap = bookmarkLocatorMap + (finalCfi to locator) + Timber.d("Native vertical click: Adding bookmark: $newBookmark") + } + } + } + } + } else { val checkVisibleBookmarks = remember(webViewRefForTts, bookmarks, currentChapterIndex) { { val currentChapter = chapters.getOrNull(currentChapterIndex) @@ -5061,6 +5938,7 @@ fun EpubReaderHost( webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiForBookmarkExtracted(window.getCurrentCfi());", null) } } + } } RenderMode.PAGINATED -> { val pageContent = remember(paginatedPagerState.currentPage, paginator) { @@ -5147,14 +6025,23 @@ fun EpubReaderHost( .padding(end = 16.dp) ) + val pageInfoChromeTopPadding = + if (pageInfoPosition == PageInfoPosition.TOP && showBars) 55.dp else 0.dp + val pageInfoChromeBottomPadding = + if (pageInfoPosition == PageInfoPosition.BOTTOM && showBars) { + bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp + } else { + 0.dp + } + // Page Info Bar (Vertical) AnimatedVisibility( visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), - modifier = Modifier.align( - if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter - ) + modifier = Modifier + .align(if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter) + .padding(top = pageInfoChromeTopPadding, bottom = pageInfoChromeBottomPadding) ) { Box( modifier = Modifier @@ -5169,7 +6056,12 @@ fun EpubReaderHost( chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim() ?: "Chapter" - val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)" + val displayPageInfo = when { + isNativeVerticalMode && nativeVerticalTotalPages > 0 -> + " (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)" + currentScrollHeightValue <= 0 || isChapterParsing -> "" + else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)" + } Text( text = "$chapterTitle$displayPageInfo", @@ -5183,7 +6075,7 @@ fun EpubReaderHost( .padding(horizontal = 48.dp) ) - if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) { + if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && (!isChapterParsing || isNativeVerticalMode)) { Text( text = "%.1f%%".format(currentBookProgress), style = MaterialTheme.typography.bodySmall, @@ -5200,9 +6092,9 @@ fun EpubReaderHost( visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), - modifier = Modifier.align( - if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter - ) + modifier = Modifier + .align(if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter) + .padding(top = pageInfoChromeTopPadding, bottom = pageInfoChromeBottomPadding) ) { Box( modifier = Modifier @@ -5250,7 +6142,7 @@ fun EpubReaderHost( if (paginatedPagerState.pageCount > 0) { if (totalBookLengthChars > 0 && bookPaginator != null && chapterIndex != null) { val completedCharsInPreviousChapters = remember(chapters, chapterIndex) { - chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } + chapters.take(chapterIndex).sumOf { it.plainTextCharacterCount().toLong() } } val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] val currentPageInChapter = if (chapterStartPage != null) { @@ -5502,6 +6394,7 @@ fun EpubReaderHost( volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isRightToLeftPagination = rightToLeftPagination, + useNativeVerticalRenderer = useNativeVerticalRenderer, hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, @@ -5518,17 +6411,81 @@ fun EpubReaderHost( keyboardController?.hide() focusManager.clearFocus() containerFocusRequester.requestFocus() - webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) + if (!isNativeVerticalMode) { + webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) + } + }, + onUseNativeVerticalRendererChange = { enabled -> + val wasNativeVertical = isNativeVerticalMode + val nativeLocator = if (wasNativeVertical) { + currentNativeVerticalLocator() ?: lastKnownLocator + } else { + null + } + useNativeVerticalRenderer = enabled + if (enabled) { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !wasNativeVertical) { + val bookPaginator = paginator as? BookPaginator + val chapterStartPage = bookPaginator?.chapterStartPageIndices?.get(currentChapterIndex) + val chapterPageCount = bookPaginator?.chapterPageCounts?.get(currentChapterIndex) + if (chapterStartPage != null && chapterPageCount != null && chapterPageCount > 0) { + val pageRatio = if (totalPagesInCurrentChapter > 1) { + (currentPageInChapter - 1).toFloat() / (totalPagesInCurrentChapter - 1).toFloat() + } else { + 0f + } + nativeVerticalScrollRequest = + chapterStartPage + (pageRatio * (chapterPageCount - 1)).roundToInt() + } + } + webViewRefForTts = null + isAutoScrollModeActive = false + isAutoScrollPlaying = false + } else if (wasNativeVertical && nativeLocator != null) { + lastKnownLocator = nativeLocator + initialScrollTargetForChapter = null + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = nativeLocator.chapterIndex + scope.launch { + val cfi = locatorConverter.getCfiFromLocator(epubBook, nativeLocator) + cfiToLoad = cfi + } + } }, onChangeRenderMode = { newMode -> Timber.tag("NavDiag").d("onChangeRenderMode to $newMode") if (newMode != currentRenderMode) { if (newMode == RenderMode.PAGINATED) { isSwitchingToPaginated = true - webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + if (isNativeVerticalMode) { + isSwitchingToPaginated = false + val locator = currentNativeVerticalLocator() ?: lastKnownLocator + if (locator != null) { + lastKnownLocator = locator + chapterToLoadOnSwitch = locator.chapterIndex + } + isPagerInitialized = false + currentRenderMode = RenderMode.PAGINATED + onRenderModeChange(RenderMode.PAGINATED) + } else { + webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + } } else { scope.launch { Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator") + if (useNativeVerticalRenderer) { + val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) + ?: lastKnownLocator + if (locator != null) { + lastKnownLocator = locator + } + nativeVerticalScrollRequest = paginatedPagerState.currentPage + webViewRefForTts = null + currentRenderMode = RenderMode.VERTICAL_SCROLL + onRenderModeChange(RenderMode.VERTICAL_SCROLL) + return@launch + } lastKnownLocator?.let { locator -> val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) Timber.tag("NavDiag").d("Converted locator to CFI: $cfi") @@ -5587,6 +6544,7 @@ fun EpubReaderHost( modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsReplacements = { showTtsReplacementsSheet = true }, + onOpenBookReplacements = { showBookReplacementsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenBrightness = { showBrightnessSheet = true }, @@ -5663,7 +6621,7 @@ fun EpubReaderHost( ) val ttsAlignmentBias by animateFloatAsState( - targetValue = if (isTtsCollapsed) 1f else 0f, + targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize), label = "TtsAlignAnimation" ) @@ -5680,8 +6638,11 @@ fun EpubReaderHost( ttsController = ttsController, ttsState = ttsState, currentTtsMode = currentTtsMode, - isCollapsed = isTtsCollapsed, - onCollapseChange = { isTtsCollapsed = it }, + overlaySize = ttsOverlaySize, + onOverlaySizeChange = { newSize -> + ttsOverlaySize = newSize + saveReaderTtsOverlaySize(context, newSize) + }, onLocateCurrentChunk = { logTtsChapterDiag("Locate current chunk requested from TTS overlay") queuePendingTtsLocate(TTS_LOCATE_REASON_OVERLAY) @@ -5785,7 +6746,16 @@ fun EpubReaderHost( triggerAutoScrollTempPause(1000L) } scope.launch { - webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) + if (isNativeVerticalMode) { + val chapterIndex = currentNativeVerticalLocator()?.chapterIndex ?: currentChapterIndex + requestNativeVerticalLocatorScroll( + locator = Locator(chapterIndex, 0, 0), + fallbackPage = (paginator as? BookPaginator)?.findStableChapterStartPage(chapterIndex), + fallbackChapterIndex = chapterIndex + ) + } else { + webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) + } } } ) @@ -6121,13 +7091,13 @@ fun EpubReaderHost( EpubReaderPageSlider( isVisible = epubSliderChromeVisible, - currentRenderMode = currentRenderMode, - totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, + totalPages = when { + isNativeVerticalMode -> nativeVerticalTotalPages + currentRenderMode == RenderMode.VERTICAL_SCROLL -> totalPagesInCurrentChapter + else -> paginatedPagerState.pageCount + }, sliderCurrentPage = sliderCurrentPage, sliderStartPage = sliderStartPage, - startPageThumbnail = startPageThumbnail, - paginator = paginator, - chapters = chapters, onScrub = { newValue -> sliderCurrentPage = newValue isFastScrubbing = true @@ -6136,7 +7106,14 @@ fun EpubReaderHost( delay(200) if (isActive) { val targetPage = newValue.roundToInt() - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + requestNativeVerticalProgressScroll( + nativeVerticalProgressForCompatPage( + pageIndex = targetPage - 1, + totalPageCount = nativeVerticalTotalPages + ) + ) + } else if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { val scrollY = (targetPage - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) } else { @@ -6148,7 +7125,15 @@ fun EpubReaderHost( }, onJumpToPage = { page -> scope.launch { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + if (isNativeVerticalMode) { + sliderCurrentPage = page.toFloat() + requestNativeVerticalProgressScroll( + nativeVerticalProgressForCompatPage( + pageIndex = page - 1, + totalPageCount = nativeVerticalTotalPages + ) + ) + } else if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { sliderCurrentPage = page.toFloat() val scrollY = (page - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) @@ -6164,13 +7149,15 @@ fun EpubReaderHost( .padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp), activeColor = epubReaderSliderColors.activeTrackColor, inactiveColor = epubReaderSliderColors.inactiveTrackColor, - contentColor = epubReaderSliderColors.contentColor, - thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor, - thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor + contentColor = epubReaderSliderColors.contentColor ) if (epubSliderChromeVisible && isFastScrubbing) { - val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount + val total = when { + isNativeVerticalMode -> nativeVerticalTotalPages + currentRenderMode == RenderMode.VERTICAL_SCROLL -> totalPagesInCurrentChapter + else -> paginatedPagerState.pageCount + } PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } } @@ -6205,6 +7192,15 @@ fun EpubReaderHost( onDismiss = { showTtsReplacementsSheet = false }, ) + BookWordReplacementsSheet( + isVisible = showBookReplacementsSheet, + bookId = bookId, + bookTitle = epubBook.title, + preferences = bookReplacementPreferences, + onPreferencesChange = updateBookReplacementPreferences, + onDismiss = { showBookReplacementsSheet = false }, + ) + ReaderFileInfoDialogs( isFileInfoVisible = showFileInfoDialog, onFileInfoVisibleChange = { showFileInfoDialog = it }, @@ -6328,7 +7324,7 @@ fun EpubReaderHost( currentCustomFontPath = path }, customFonts = customFonts, - onImportFont = onImportFont, + onImportFonts = onImportFonts, onDismiss = { showFontSelectionSheet = false } ) Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt index a8595c3..674a4f0 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt @@ -53,15 +53,64 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode import java.io.File import kotlin.math.max import kotlin.math.min +private const val EPUB_SEARCH_WINDOW_CHARS = 32_768 +private const val EPUB_SEARCH_SNIPPET_RADIUS = 35 +private const val EPUB_SEARCH_MAX_OVERLAP_CHARS = 4_096 + +private val epubSearchSkippedTags = setOf("script", "style", "noscript") +private val epubSearchBlockBoundaryTags = setOf( + "address", + "article", + "aside", + "blockquote", + "br", + "caption", + "dd", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "td", + "th", + "tr", + "ul" +) + /** * Creates the search implementation for EPUB chapters. */ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List = { query -> withContext(Dispatchers.Default) { + val searchQuery = query.trim() + if (searchQuery.isBlank()) { + return@withContext emptyList() + } + val results = mutableListOf() epubBook.chapters.forEachIndexed { chapterIndex, chapter -> try { @@ -69,54 +118,208 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List - val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } - val content = Jsoup.parse(chunkHtml).text() - var lastIndex = -1 - - while (true) { - lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true) - if (lastIndex == -1) break - - val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit() - if (isWordStart) { - val snippetStart = max(0, lastIndex - 35) - val snippetEnd = min(content.length, lastIndex + query.length + 35) - val rawSnippet = content.substring(snippetStart, snippetEnd) - val annotatedSnippet = buildAnnotatedString { - append(rawSnippet) - val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart - val highlightEnd = highlightStart + query.length - addStyle( - style = SpanStyle(fontWeight = FontWeight.Bold), - start = highlightStart, - end = highlightEnd - ) - } - results.add( - SearchResult( - locationInSource = chapterIndex, - locationTitle = chapter.title, - snippet = annotatedSnippet, - query = query, - occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex }, - chunkIndex = chunkIndex - ) - ) - } - } + chunks.forEachIndexed { chunkIndex, chunkNodes -> + occurrenceIndexInChapter = appendSearchResultsFromNodes( + nodes = chunkNodes, + query = searchQuery, + chapterIndex = chapterIndex, + chapterTitle = chapter.title, + chunkIndex = chunkIndex, + occurrenceIndexInChapter = occurrenceIndexInChapter, + results = results + ) } } catch (e: Exception) { - Timber.e("Failed to search in chapter $chapterIndex", e) + Timber.e(e, "Failed to search in chapter $chapterIndex") + } catch (e: OutOfMemoryError) { + Timber.e(e, "Skipping search in chapter $chapterIndex after running out of memory") } } results } } +private fun appendSearchResultsFromNodes( + nodes: List, + query: String, + chapterIndex: Int, + chapterTitle: String, + chunkIndex: Int, + occurrenceIndexInChapter: Int, + results: MutableList +): Int { + val searchWindow = EpubSearchWindow( + query = query, + chapterIndex = chapterIndex, + chapterTitle = chapterTitle, + chunkIndex = chunkIndex, + initialOccurrenceIndex = occurrenceIndexInChapter, + results = results + ) + nodes.forEach { node -> + searchWindow.visit(node) + } + searchWindow.finish() + return searchWindow.occurrenceIndex +} + +private class EpubSearchWindow( + private val query: String, + private val chapterIndex: Int, + private val chapterTitle: String, + private val chunkIndex: Int, + initialOccurrenceIndex: Int, + private val results: MutableList +) { + private val buffer = StringBuilder() + private val overlapChars = (query.length + EPUB_SEARCH_SNIPPET_RADIUS) + .coerceIn(EPUB_SEARCH_SNIPPET_RADIUS * 2, EPUB_SEARCH_MAX_OVERLAP_CHARS) + private var lastAppendedWasWhitespace = true + private var previousCharBeforeBuffer: Char? = null + + var occurrenceIndex: Int = initialOccurrenceIndex + private set + + fun visit(node: Node) { + when (node) { + is TextNode -> appendNormalizedText(node.wholeText) + is Element -> { + val tagName = node.tagName().lowercase() + if (tagName in epubSearchSkippedTags) return + + if (tagName == "br") { + appendNormalizedWhitespace() + return + } + + node.childNodes().forEach(::visit) + if (tagName in epubSearchBlockBoundaryTags) { + appendNormalizedWhitespace() + } + } + else -> node.childNodes().forEach(::visit) + } + } + + fun finish() { + scanBuffer(buffer.length) + buffer.clear() + previousCharBeforeBuffer = null + } + + private fun appendNormalizedText(text: String) { + text.forEach { char -> + if (char.isWhitespace()) { + appendNormalizedWhitespace() + } else { + buffer.append(char) + lastAppendedWasWhitespace = false + trimScannedPrefixIfNeeded() + } + } + } + + private fun appendNormalizedWhitespace() { + if (buffer.isEmpty() || lastAppendedWasWhitespace) { + lastAppendedWasWhitespace = true + return + } + buffer.append(' ') + lastAppendedWasWhitespace = true + trimScannedPrefixIfNeeded() + } + + private fun trimScannedPrefixIfNeeded() { + if (buffer.length < EPUB_SEARCH_WINDOW_CHARS) return + + val scanEndExclusive = (buffer.length - overlapChars).coerceAtLeast(0) + if (scanEndExclusive <= 0) return + + scanBuffer(scanEndExclusive) + previousCharBeforeBuffer = buffer[scanEndExclusive - 1] + buffer.delete(0, scanEndExclusive) + } + + private fun scanBuffer(scanEndExclusive: Int) { + var searchFrom = 0 + while (searchFrom < scanEndExclusive) { + val matchStart = buffer.indexOfIgnoreCase(query, searchFrom, scanEndExclusive) + if (matchStart == -1) break + + if (isWordStart(matchStart)) { + addSearchResult(matchStart) + } + searchFrom = matchStart + 1 + } + } + + private fun isWordStart(matchStart: Int): Boolean { + val previousChar = if (matchStart > 0) { + buffer[matchStart - 1] + } else { + previousCharBeforeBuffer + } + return previousChar == null || !previousChar.isLetterOrDigit() + } + + private fun addSearchResult(matchStart: Int) { + val snippetStart = max(0, matchStart - EPUB_SEARCH_SNIPPET_RADIUS) + val snippetEnd = min(buffer.length, matchStart + query.length + EPUB_SEARCH_SNIPPET_RADIUS) + val rawSnippet = buffer.substring(snippetStart, snippetEnd) + val highlightStart = matchStart - snippetStart + val highlightEnd = highlightStart + query.length + val annotatedSnippet = buildAnnotatedString { + append(rawSnippet) + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = highlightStart, + end = highlightEnd + ) + } + + results.add( + SearchResult( + locationInSource = chapterIndex, + locationTitle = chapterTitle, + snippet = annotatedSnippet, + query = query, + occurrenceIndexInLocation = occurrenceIndex, + chunkIndex = chunkIndex + ) + ) + occurrenceIndex++ + } +} + +private fun CharSequence.indexOfIgnoreCase( + query: String, + startIndex: Int, + matchStartLimitExclusive: Int +): Int { + if (query.isEmpty()) return -1 + val lastStart = min(length - query.length, matchStartLimitExclusive - 1) + if (lastStart < startIndex) return -1 + + var index = startIndex.coerceAtLeast(0) + while (index <= lastStart) { + var queryIndex = 0 + while ( + queryIndex < query.length && + this[index + queryIndex].equals(query[queryIndex], ignoreCase = true) + ) { + queryIndex++ + } + if (queryIndex == query.length) return index + index++ + } + return -1 +} + /** * Handles the navigation to a specific search result. */ diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index ad354f6..16d3818 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -96,6 +96,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -106,6 +108,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.edit import com.aryan.reader.R import com.aryan.reader.data.CustomFontEntity +import com.aryan.reader.supportedFontMimeTypes import java.io.File import kotlin.math.roundToInt @@ -130,6 +133,7 @@ private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode" private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode" private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position" private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled" +private const val NATIVE_VERTICAL_RENDERER_KEY = "reader_native_vertical_renderer" const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_LINE_HEIGHT_VAL = 1.0f @@ -295,6 +299,16 @@ fun loadPullToTurn(context: Context): Boolean { return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true) } +fun saveNativeVerticalRenderer(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(NATIVE_VERTICAL_RENDERER_KEY, enabled) } +} + +fun loadNativeVerticalRenderer(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(NATIVE_VERTICAL_RENDERER_KEY, false) +} + private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier" fun savePullToTurnMultiplier(context: Context, multiplier: Float) { @@ -603,6 +617,7 @@ fun ReaderTextFormatPanel( ) // Font Button + val fontSelectorDescription = stringResource(R.string.content_desc_select_font_family) Surface( onClick = onFontOptionClick, shape = RoundedCornerShape(12.dp), @@ -610,6 +625,9 @@ fun ReaderTextFormatPanel( modifier = Modifier .fillMaxWidth() .height(52.dp) + .semantics { + contentDescription = fontSelectorDescription + } ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -769,12 +787,12 @@ fun FontSelectionSheetContent( currentCustomFontPath: String?, onFontSelected: (ReaderFont, String?) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit, + onImportFonts: (List) -> Unit, onDismiss: () -> Unit ) { var selectedTabIndex by remember { mutableIntStateOf(0) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { onImportFont(it) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) onImportFonts(uris) } Column(modifier = Modifier.fillMaxWidth()) { @@ -817,7 +835,7 @@ fun FontSelectionSheetContent( Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Button( - onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) }, + onClick = { launcher.launch(supportedFontMimeTypes()) }, modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.Add, contentDescription = null) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsState.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsState.kt new file mode 100644 index 0000000..febc5d1 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsState.kt @@ -0,0 +1,14 @@ +package com.aryan.reader.epubreader + +import com.aryan.reader.shared.PageInfoMode + +internal fun shouldShowEpubPageInfoBar( + pageInfoMode: PageInfoMode, + showReaderChrome: Boolean +): Boolean { + return when (pageInfoMode) { + PageInfoMode.DEFAULT -> true + PageInfoMode.SYNC -> showReaderChrome + PageInfoMode.HIDDEN -> false + } +} diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt index 068869a..3223641 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt @@ -116,7 +116,7 @@ class OpdsRepository(context: Context) : SharedOpdsRepository { if (wwwAuth.startsWith("Digest", ignoreCase = true)) { val realm = extractParam(wwwAuth, "realm") ?: "" val nonce = extractParam(wwwAuth, "nonce") ?: "" - val qop = extractParam(wwwAuth, "qop") + val qop = selectAuthQop(extractParam(wwwAuth, "qop")) val opaque = extractParam(wwwAuth, "opaque") cnonceCount++ @@ -162,6 +162,13 @@ class OpdsRepository(context: Context) : SharedOpdsRepository { return match?.groupValues?.get(1) } + private fun selectAuthQop(value: String?): String? { + return value + ?.split(',') + ?.map { it.trim().trim('"') } + ?.firstOrNull { it.equals("auth", ignoreCase = true) } + } + private fun md5(input: String): String { val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) return bytes.joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt index 370b5f8..77363d7 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt @@ -2,6 +2,7 @@ package com.aryan.reader.paginatedreader import android.graphics.BitmapFactory import androidx.compose.ui.text.font.FontFamily +import com.aryan.reader.epub.safeFileInRoot import java.io.File import java.net.URLDecoder import java.nio.file.Paths @@ -16,12 +17,14 @@ object AndroidHtmlResourceResolver : HtmlResourceResolver { } val parentPath = File(chapterAbsPath).parent ?: "" val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString() - val fromRelativeFile = File(extractionBasePath, relativePath) return try { + val extractionRoot = File(extractionBasePath) + val fromRelativeFile = safeFileInRoot(extractionRoot, relativePath) + val fromRootFile = safeFileInRoot(extractionRoot, decodedSrc) when { - fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath - File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath + fromRelativeFile?.exists() == true -> fromRelativeFile.absolutePath + fromRootFile?.exists() == true -> fromRootFile.absolutePath else -> null } } catch (_: Exception) { 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 302131b..f9642f6 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -37,8 +37,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import com.aryan.reader.SearchResult +import com.aryan.reader.applyBookReplacementsToHtmlDocument import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.contentFilePath +import com.aryan.reader.epub.plainTextCharacterCount import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookProcessingInput import com.aryan.reader.paginatedreader.data.BookProcessingWorker @@ -50,14 +52,20 @@ import com.aryan.reader.paginatedreader.data.PageIndexEntry import com.aryan.reader.paginatedreader.data.ProcessedBook import com.aryan.reader.paginatedreader.data.ProcessedChapter import com.aryan.reader.paginatedreader.data.SerializableEpubChapter +import com.aryan.reader.shared.ReaderBookReplacementPreferences +import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson import com.aryan.reader.tts.PageCharacterRange import com.aryan.reader.tts.splitTextIntoChunks import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -74,6 +82,8 @@ import java.net.URI import java.net.URLDecoder import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.coroutines.coroutineContext private const val PRIORITY_HIGHEST = 0 private const val PRIORITY_HIGH = 1 @@ -134,7 +144,7 @@ private data class PageNavigationEntry( @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Stable class BookPaginator( - private val coroutineScope: CoroutineScope, + coroutineScope: CoroutineScope, private val chapters: List, private val textMeasurer: TextMeasurer, private val constraints: Constraints, @@ -157,7 +167,9 @@ class BookPaginator( private val userTextAlign: TextAlign?, private val paragraphGapMultiplier: Float, private val imageSizeMultiplier: Float, - private val verticalMarginMultiplier: Float + private val verticalMarginMultiplier: Float, + private val bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + private val bookReplacementFileId: String? = null ) : IPaginator { override var totalPageCount by mutableIntStateOf(0) private set @@ -200,8 +212,27 @@ class BookPaginator( private val paginationQueue = PriorityBlockingQueue() private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet() private val chapterPaginationLocks = ConcurrentHashMap() + private val chapterBlockLocks = ConcurrentHashMap() private val navigationCallbacks = ConcurrentHashMap) -> Unit>>() private var paginationWorker: Job? = null + private val paginatorJob = SupervisorJob(coroutineScope.coroutineContext[Job]) + private val paginatorScope = CoroutineScope(coroutineScope.coroutineContext + paginatorJob) + @Volatile + private var disposed = false + + override fun dispose() { + if (disposed) return + disposed = true + paginationQueue.clear() + navigationCallbacks.clear() + chaptersBeingProcessed.clear() + paginationWorker?.cancel() + paginatorJob.cancel(CancellationException("BookPaginator disposed")) + isLoading = false + Timber.i("BookPaginator disposed for book=$bookId configHash=$currentConfigHash") + } + + private fun isDisposed(): Boolean = disposed || !paginatorJob.isActive internal fun getCharactersScrolledInChapter(chapterIndex: Int, pageInChapter: Int): Long { val cumulativeCharsList = chapterCumulativeChars[chapterIndex] @@ -224,7 +255,7 @@ class BookPaginator( Timber.e("Paginator received UNBOUNDED HEIGHT. Pagination will fail.") } else { Timber.i("Paginator initializing with constraints: $constraints") - coroutineScope.launch { + paginatorScope.launch { isLoading = true Timber.d("Initialization started.") @@ -236,22 +267,46 @@ class BookPaginator( return@launch } - // 1. Book processing check (Keep existing logic) + // 1. Generate config hash before touching semantic cache; processed chapters are style-sensitive. + coroutineContext.ensureActive() + currentConfigHash = generateConfigurationHash() + coroutineContext.ensureActive() + + // 2. Book processing check (Keep existing logic) val bookRecord = bookCacheDao.getProcessedBook(bookId) + var shouldEnqueueBookProcessing = false if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) { Timber.i("Book cache is new or stale. Creating initial record.") bookCacheDao.deleteEntireBookCache(bookId) val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0 bookCacheDao.insertProcessedBook(initialBook) - enqueueBookProcessingWork() + shouldEnqueueBookProcessing = true + } else if (bookCacheDao.getProcessedChapter( + bookId, + initialChapterToPaginate.coerceIn(0, chapters.lastIndex), + currentConfigHash + ) == null + ) { + Timber.i("Semantic chapter cache is missing for current style config. Enqueuing config-aware processing.") + shouldEnqueueBookProcessing = true } - // 2. GENERATE CONFIG HASH - currentConfigHash = generateConfigurationHash() + coroutineContext.ensureActive() + if (isDisposed()) return@launch + + if (shouldEnqueueBookProcessing) { + enqueueBookProcessingWork() + } else { + BookProcessingWorker.cancelForBook(context, bookId) + } // 3. TRY LOAD EXACT COUNTS FROM DB + coroutineContext.ensureActive() + if (isDisposed()) return@launch val cachedConfig = bookCacheDao.getConfigurationCache(bookId, currentConfigHash) + coroutineContext.ensureActive() + if (isDisposed()) return@launch if (cachedConfig != null) { Timber.i("Configuration Cache HIT. Using saved page counts.") applyAccuratePageCounts(cachedConfig.chapterPageCounts) @@ -308,15 +363,7 @@ class BookPaginator( } private fun getAllTextBlocks(blocks: List): List { - return blocks.flatMap { block -> - when (block) { - is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap) - is FlexContainerBlock -> getAllTextBlocks(block.children) - is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) } - is TextContentBlock -> listOf(block) - else -> emptyList() - } - } + return flattenTextContentBlocksForNavigation(blocks) } private fun generateConfigurationHash(): Int { @@ -326,10 +373,14 @@ class BookPaginator( append("-fs:${textStyle.fontSize.value}") append("-lh:${textStyle.lineHeight.value}") append("-ff:${textStyle.fontFamily}") + append("-style:${textStyle.hashCode()}") + append("-density:${density.density}") + append("-fontScale:${density.fontScale}") append("-ta:$userTextAlign") append("-pg:$paragraphGapMultiplier") append("-img:$imageSizeMultiplier") append("-vm:$verticalMarginMultiplier") + append("-book-replacements:${bookReplacementPreferences.signatureForFile(bookReplacementFileId)}") append("-proc:$LATEST_PROCESSING_VERSION") append("-pageCache:$LATEST_PAGE_CACHE_VERSION") append("-ua:${userAgentStylesheet.hashCode()}") @@ -413,7 +464,7 @@ class BookPaginator( append('|') append(chapter.htmlContent.hashCode()) append('|') - append(chapter.plainTextContent.length) + append(chapter.plainTextCharacterCount()) append('|') append(chapter.plainTextContent.hashCode()) append('|') @@ -426,13 +477,24 @@ class BookPaginator( } private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List? { - val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null + val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: run { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=miss chapter=$chapterIndex configHash=$currentConfigHash" + ) + return null + } val expectedContentVersion = chapterContentVersion(chapter) val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION && cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION && cachedPages.contentVersion == expectedContentVersion if (!isCompatible) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=stale chapter=$chapterIndex " + + "cachedProcessing=${cachedPages.processingVersion} expectedProcessing=$LATEST_PROCESSING_VERSION " + + "cachedPageCache=${cachedPages.pageCacheVersion} expectedPageCache=$LATEST_PAGE_CACHE_VERSION " + + "cachedContent=${cachedPages.contentVersion} expectedContent=$expectedContentVersion" + ) Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.") return null } @@ -446,6 +508,10 @@ class BookPaginator( applyPageRuntimeIndexes(chapterIndex, pages) updatePageCountsOnMain(chapterIndex, pages.size) pageCache.put(chapterIndex, pages) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_lookup result=hit chapter=$chapterIndex configHash=$currentConfigHash " + + pages.readerPagesLinkDiagSummary() + ) Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.") pages } @@ -456,8 +522,14 @@ class BookPaginator( } private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch try { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_cache_save chapter=$chapterIndex configHash=$currentConfigHash " + + pages.readerPagesLinkDiagSummary() + ) val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages) val cacheEntry = PageCacheEntry( bookId = bookId, @@ -578,16 +650,19 @@ class BookPaginator( private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) { withContext(Dispatchers.Main) { + if (isDisposed()) return@withContext if (chapterPageCounts[chapterIndex] != actualPageCount) { updatePageCounts(chapterIndex, actualPageCount) } else if (finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } generation++ } } private suspend fun ensureChapterPaginated(chapterIndex: Int): List? { + coroutineContext.ensureActive() + if (isDisposed()) return null if (chapterIndex !in chapters.indices) { Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.") return null @@ -612,6 +687,29 @@ class BookPaginator( } } + private suspend fun getCachedBlocksForChapter(chapter: EpubChapter, chapterIndex: Int): List { + blockCache[chapterIndex]?.let { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_l2_cache_hit chapter=$chapterIndex " + it.readerContentLinkDiagSummary() + ) + return it + } + + val lock = chapterBlockLocks.computeIfAbsent(chapterIndex) { Mutex() } + return lock.withLock { + blockCache[chapterIndex]?.also { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_l2_cache_hit_after_wait chapter=$chapterIndex " + it.readerContentLinkDiagSummary() + ) + } ?: run { + Timber.d("getCachedBlocksForChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.") + getBlocksForChapter(chapter, chapterIndex).also { blocks -> + blockCache.put(chapterIndex, blocks) + } + } + } + } + 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}" @@ -672,7 +770,7 @@ class BookPaginator( ordinalInChapter: Int ): Pair? = withContext(Dispatchers.IO) { val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null - val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex)) + val imageBlocks = getAllBlocks(getCachedBlocksForChapter(chapter, chapterIndex)) .filterIsInstance() if (imageBlocks.isEmpty()) return@withContext null @@ -756,6 +854,7 @@ class BookPaginator( } private fun enqueueBookProcessingWork() { + if (isDisposed()) return val serializableChapters = chapters.map { SerializableEpubChapter( htmlContent = it.htmlContent, @@ -773,9 +872,15 @@ class BookPaginator( density = density.density, constraintsMaxWidth = constraints.maxWidth, constraintsMaxHeight = constraints.maxHeight, - fontFaces = this.allFontFaces + fontFaces = this.allFontFaces, + styleConfigHash = currentConfigHash, + bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode( + bookReplacementPreferences.scopedToFile(bookReplacementFileId), + ), + bookReplacementFileId = bookReplacementFileId.orEmpty() ) + if (isDisposed()) return BookProcessingWorker.enqueue( context = context, bookId = bookId, @@ -802,10 +907,14 @@ class BookPaginator( adaptThemeColors = false ) - bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter -> + bookCacheDao.getProcessedChapter(bookId, chapterIndex, currentConfigHash)?.let { cachedChapter -> if (cachedChapter.contentBlocksProto.isNotEmpty()) { try { val semanticBlocks = proto.decodeFromByteArray>(cachedChapter.contentBlocksProto) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "semantic_cache_hit chapter=$chapterIndex configHash=$currentConfigHash " + + semanticBlocks.readerSemanticLinkDiagSummary() + ) val isCacheEmpty = semanticBlocks.isEmpty() val isLazyChapter = chapter.htmlContent.isEmpty() @@ -821,7 +930,12 @@ class BookPaginator( if (!shouldIgnoreCache) { Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.") - return styler.style(semanticBlocks) + val styledBlocks = styler.style(semanticBlocks) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_from_semantic_cache chapter=$chapterIndex configHash=$currentConfigHash " + + styledBlocks.readerContentLinkDiagSummary() + ) + return styledBlocks } } catch (e: Exception) { Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.") @@ -849,6 +963,10 @@ class BookPaginator( } val document = Jsoup.parse(htmlToParse, chapter.absPath) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "html_parse_input chapter=$chapterIndex htmlChars=${htmlToParse.length} " + + document.readerHtmlLinkDiagSummary() + ) val mathElements = document.select("math") val svgResults = mutableMapOf() @@ -865,6 +983,11 @@ class BookPaginator( element.replaceWith(placeholder) } } + applyBookReplacementsToHtmlDocument( + document = document, + preferences = bookReplacementPreferences, + fileId = bookReplacementFileId, + ) val processedHtml = document.outerHtml() var parsingCssRules = OptimizedCssRules() @@ -887,11 +1010,15 @@ class BookPaginator( mathSvgCache = svgResults, adaptThemeColors = false ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "semantic_parse_result chapter=$chapterIndex " + + semanticBlocks.readerSemanticLinkDiagSummary() + ) - coroutineScope.launch(Dispatchers.IO) { + if (!isDisposed()) paginatorScope.launch(Dispatchers.IO) { try { val protoBytes = proto.encodeToByteArray(semanticBlocks) - val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0) + val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0, currentConfigHash) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) Timber.i("Successfully cached SEMANTIC content for chapter $chapterIndex.") } catch (e: Exception) { @@ -899,15 +1026,27 @@ class BookPaginator( } } - return styler.style(semanticBlocks) + val styledBlocks = styler.style(semanticBlocks) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "content_parse_result chapter=$chapterIndex " + + styledBlocks.readerContentLinkDiagSummary() + ) + return styledBlocks } - private fun startPaginationWorker(): Job = coroutineScope.launch(Dispatchers.IO) { + internal suspend fun getFlowBlocksForChapter(chapterIndex: Int): List? = withContext(Dispatchers.IO) { + coroutineContext.ensureActive() + if (isDisposed()) return@withContext null + val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null + getCachedBlocksForChapter(chapter, chapterIndex) + } + + private fun startPaginationWorker(): Job = paginatorScope.launch(Dispatchers.IO) { Timber.i("Pagination worker started.") while (isActive) { var request: PaginationRequest? = null try { - request = paginationQueue.take() + request = paginationQueue.poll(250, TimeUnit.MILLISECONDS) ?: continue val chapterIndex = request.chapterIndex Timber.d("Worker: Took chapter $chapterIndex from queue with priority ${request.priority}.") @@ -933,6 +1072,9 @@ class BookPaginator( } else { Timber.e("Worker: Pagination for chapter $chapterIndex resulted in null.") } + } catch (e: CancellationException) { + Timber.i("Pagination worker cancelled. Shutting down.") + throw e } catch (_: InterruptedException) { Timber.i("Pagination worker interrupted. Shutting down.") Thread.currentThread().interrupt() @@ -968,7 +1110,7 @@ class BookPaginator( "page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}" ) if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } return } @@ -1000,7 +1142,7 @@ class BookPaginator( if (!pageCountsAreAccurate) { if (finalizedChapterCounts.add(chapterIndex)) { - coroutineScope.launch(Dispatchers.IO) { + paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } } @@ -1041,6 +1183,7 @@ class BookPaginator( } override fun getPageContent(pageIndex: Int): Page? { + if (isDisposed()) return null Timber.v("getPageContent requested for pageIndex $pageIndex") val chapterIndex = findChapterIndexForPage(pageIndex) if (chapterIndex == null) { @@ -1127,8 +1270,13 @@ class BookPaginator( } private suspend fun paginateChapter(chapterIndex: Int): List? { + coroutineContext.ensureActive() + if (isDisposed()) return null pageCache[chapterIndex]?.let { Timber.d("paginateChapter: L1 Cache HIT for chapter $chapterIndex in MEMORY, returning cached pages.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_memory_cache_hit chapter=$chapterIndex " + it.readerPagesLinkDiagSummary() + ) return it } @@ -1142,12 +1290,9 @@ class BookPaginator( return it } - val blocks = blockCache[chapterIndex] ?: run { - Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.") - val blocksFromDb = getBlocksForChapter(chapter, chapterIndex) - blockCache.put(chapterIndex, blocksFromDb) // Store in L2 cache - blocksFromDb - } + val blocks = getCachedBlocksForChapter(chapter, chapterIndex) + coroutineContext.ensureActive() + if (isDisposed()) return null Timber.d("paginateChapter: Chapter $chapterIndex retrieved/parsed into ${blocks.size} content blocks.") @@ -1165,7 +1310,12 @@ class BookPaginator( measurementProvider = measurementProvider, density = density ) + coroutineContext.ensureActive() + if (isDisposed()) return null Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "pagination_result chapter=$chapterIndex " + pages.readerPagesLinkDiagSummary() + ) applyPageRuntimeIndexes(chapterIndex, pages) savePageCacheAsync(chapter, chapterIndex, pages) @@ -1177,6 +1327,7 @@ class BookPaginator( } private fun triggerPagination(chapterIndex: Int, priority: Int) { + if (isDisposed()) return if (chapterIndex !in chapters.indices) { Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.") return @@ -1209,6 +1360,7 @@ class BookPaginator( } private fun prefetchChapters(currentChapterIndex: Int) { + if (isDisposed()) return Timber.v("Prefetching chapters around index $currentChapterIndex.") for (offset in 1..2) { val nextChapterIndex = currentChapterIndex + offset @@ -1307,12 +1459,32 @@ class BookPaginator( finalPage } + suspend fun findStableLocatorForAnchor(chapterIndex: Int, anchor: String?): Locator? = withContext(Dispatchers.IO) { + if (anchor.isNullOrBlank()) return@withContext Locator(chapterIndex, 0, 0) + + val requestedChapter = chapters.getOrNull(chapterIndex) ?: return@withContext null + val requestedBlocks = getCachedBlocksForChapter(requestedChapter, chapterIndex) + findLocatorForAnchorInBlocks(chapterIndex, anchor, requestedBlocks)?.let { locator -> + return@withContext locator + } + + val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor) + val targetChapter = indexEntry?.chapterIndex ?: chapterIndex + val chapter = chapters.getOrNull(targetChapter) ?: return@withContext null + val blocks = getCachedBlocksForChapter(chapter, targetChapter) + + findLocatorForAnchorInBlocks(targetChapter, anchor, blocks) + ?: indexEntry?.let { Locator(it.chapterIndex, it.blockIndex, 0) } + } + override fun findPageForAnchor( chapterIndex: Int, anchor: String?, onResult: (pageIndex: Int) -> Unit ) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch withContext(Dispatchers.Main) { onResult(page) } } @@ -1365,7 +1537,9 @@ class BookPaginator( href: String, onNavigationComplete: (pageIndex: Int) -> Unit ) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch withContext(Dispatchers.Main) { onNavigationComplete(targetPage) } } @@ -1389,10 +1563,33 @@ class BookPaginator( findStablePageForAnchor(targetChapterIndex, anchor) } + suspend fun findStableLocatorForHref(currentChapterAbsPath: String, href: String): Locator? = withContext(Dispatchers.IO) { + 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 + } + + findStableLocatorForAnchor(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") + findStableLocatorForSearchResult(result)?.let { locator -> + findStablePageForLocator(locator)?.let { page -> + Timber.i("Found exact search result locator $locator on absolute page $page") + return@withContext page + } + } + val chapterPages = ensureChapterPaginated(targetChapterIndex) val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex) @@ -1435,8 +1632,16 @@ class BookPaginator( finalPageIndex } + suspend fun findStableLocatorForSearchResult(result: SearchResult): Locator? = withContext(Dispatchers.IO) { + val chapter = chapters.getOrNull(result.locationInSource) ?: return@withContext null + val blocks = getCachedBlocksForChapter(chapter, result.locationInSource) + findLocatorForSearchResultInBlocks(result, blocks) + } + override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch val page = findStablePageForSearchResult(result) ?: return@launch withContext(Dispatchers.Main) { onResult(page) } } @@ -1617,7 +1822,9 @@ class BookPaginator( } override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) { - coroutineScope.launch(Dispatchers.IO) { + if (isDisposed()) return + paginatorScope.launch(Dispatchers.IO) { + if (isDisposed()) return@launch Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'") val chapterPages = ensureChapterPaginated(chapterIndex) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt index 6006836..975845e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt @@ -122,7 +122,11 @@ class ContentStyler( return when (block) { is SemanticParagraph -> { - val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign + val computedTextAlign = when { + userTextAlign != null -> userTextAlign + themedStyle.paragraphStyle.textAlign == TextAlign.Justify -> TextAlign.Left + else -> themedStyle.paragraphStyle.textAlign + } ParagraphBlock( content = buildAnnotatedString(block, themedStyle), @@ -411,7 +415,10 @@ class ContentStyler( withStyle(finalParagraphStyle) { withStyle(initialSpanStyle) { append(block.text) + val linkSpans = mutableListOf() block.spans.sortedBy { it.start }.forEach { span -> + val spanStart = span.start.coerceIn(0, block.text.length) + val spanEnd = span.end.coerceIn(spanStart, block.text.length) val themedSpanStyle = applyThemeToStyle(span.style) val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap) val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) { @@ -434,6 +441,7 @@ class ContentStyler( ) if (!span.linkHref.isNullOrBlank()) { + linkSpans.add(span) finalSpanStyle = finalSpanStyle.withReaderLinkStyle( isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, @@ -459,31 +467,58 @@ class ContentStyler( val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0" val annotationData = "$styleStr|$colorStr|$offsetStr" - addStringAnnotation("CustomUnderline", annotationData, span.start, span.end) + if (spanStart < spanEnd) { + addStringAnnotation("CustomUnderline", annotationData, spanStart, spanEnd) + } } - addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end) + if (spanStart < spanEnd) { + addStyle(initialSpanStyle.merge(finalSpanStyle), spanStart, spanEnd) + } val ws = themedSpanStyle.wordSpacing - if (ws.isSpecified && ws.value != 0f) { - val textToStyle = block.text.substring(span.start, span.end) + if (ws.isSpecified && ws.value != 0f && spanStart < spanEnd) { + val textToStyle = block.text.substring(spanStart, spanEnd) for (i in textToStyle.indices) { if (textToStyle[i] == ' ') { - addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1) + addStyle(SpanStyle(letterSpacing = ws), spanStart + i, spanStart + i + 1) } } } - span.linkHref?.let { linkHref -> - addStringAnnotation("URL", linkHref, span.start, span.end) + span.linkHref?.takeIf { it.isNotBlank() }?.let { linkHref -> + if (spanStart < spanEnd) { + addStringAnnotation("URL", linkHref, spanStart, spanEnd) + } } span.elementId?.let { elementId -> - addStringAnnotation("ID", elementId, span.start, span.end) + addStringAnnotation("ID", elementId, spanStart, spanEnd) + } + } + + val forcedLinkStyle = readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + linkSpans.forEach { span -> + val start = span.start.coerceIn(0, block.text.length) + val end = span.end.coerceIn(start, block.text.length) + if (start < end) { + addStyle(forcedLinkStyle, start, end) } } } } } + if (block.spans.any { !it.linkHref.isNullOrBlank() }) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "style_text_block type=${block::class.simpleName ?: "Text"} " + + "block=${block.blockIndex} cfi=${block.cfi} " + + "rawLinkSpans=${block.spans.count { !it.linkHref.isNullOrBlank() }} " + + builtString.readerAnnotatedLinkDiagSummary() + ) + } return builtString.maybeAdjustLineHeightForEmphasis() } @@ -562,7 +597,10 @@ class ContentStyler( fontFamilyMap: Map ): FontFamily? { if (fontFamilyNames.isEmpty()) return null - val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] } + val normalizedMap = fontFamilyMap.entries.associate { it.key.trim().lowercase() to it.value } + val specificFont = fontFamilyNames.firstNotNullOfOrNull { name -> + normalizedMap[name.trim().removeSurrounding("\"").removeSurrounding("'").lowercase()] + } if (specificFont != null) return specificFont return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt index 3b84e67..6f4bddf 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/IPaginator.kt @@ -53,4 +53,5 @@ interface IPaginator { fun getCfiForPage(pageIndex: Int): String? fun onUserScrolledTo(pageIndex: Int) fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List): String? -} \ No newline at end of file + fun dispose() = Unit +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index a721b46..ad6de20 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.ProcessedChapter @@ -36,6 +37,9 @@ import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf import java.io.File +private const val MAX_LOCATOR_ON_DEMAND_HTML_BYTES = 2L * 1024L * 1024L +private const val MAX_LOCATOR_ON_DEMAND_HTML_CHARS = 2 * 1024 * 1024 + data class Locator( val chapterIndex: Int, val blockIndex: Int, @@ -66,21 +70,9 @@ class LocatorConverter( try { val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null - val htmlToParse = chapter.htmlContent.ifBlank { - try { - val file = File(book.extractionBasePath, chapter.contentFilePath()) - if (file.exists()) { - val content = file.readText() - content - } else { - "" - } - } catch (_: Exception) { - "" - } - } + val htmlToParse = readChapterHtmlForLocator(book, chapter, chapterIndex) - if (htmlToParse.isBlank()) { + if (htmlToParse.isNullOrBlank()) { return@withContext null } @@ -149,22 +141,77 @@ class LocatorConverter( ) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) semanticBlocks + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while processing locator cache for chapter $chapterIndex") + null } catch (_: Exception) { null } } + private fun readChapterHtmlForLocator( + book: EpubBook, + chapter: EpubChapter, + chapterIndex: Int + ): String? { + chapter.htmlContent.takeIf { it.isNotBlank() }?.let { inlineHtml -> + if (inlineHtml.length > MAX_LOCATOR_ON_DEMAND_HTML_CHARS) { + Timber.w( + "Skipping on-demand locator processing for chapter $chapterIndex: " + + "inline HTML is ${inlineHtml.length} chars" + ) + return null + } + return inlineHtml + } + + return try { + val file = File(book.extractionBasePath, chapter.contentFilePath()) + if (!file.isFile) return null + if (file.length() > MAX_LOCATOR_ON_DEMAND_HTML_BYTES) { + Timber.w( + "Skipping on-demand locator processing for chapter $chapterIndex: " + + "HTML file is ${file.length()} bytes" + ) + return null + } + file.bufferedReader().use { it.readText() } + } catch (_: Exception) { + null + } + } + + private fun decodeCachedBlocks( + processedChapter: ProcessedChapter?, + chapterIndex: Int + ): List? { + if (processedChapter == null || processedChapter.contentBlocksProto.isEmpty()) { + return null + } + return try { + proto.decodeFromByteArray>(processedChapter.contentBlocksProto) + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while decoding locator cache for chapter $chapterIndex") + null + } catch (_: Exception) { + null + } + } + + private suspend fun getProcessedChapterSafely(bookId: String, chapterIndex: Int): ProcessedChapter? { + return try { + bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = chapterIndex) + } catch (e: OutOfMemoryError) { + Timber.e(e, "Out of memory while loading locator cache for chapter $chapterIndex") + null + } + } + suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) - var allBlocks: List? = null - - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, chapterIndex, bookId) @@ -174,17 +221,33 @@ class LocatorConverter( return@withContext null } - val (baseCfiPath, charOffset) = cfi.split(':').let { - it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0) + val firstCfiPoint = cfi.substringBefore('|') + val cfiOffsetSeparator = firstCfiPoint.lastIndexOf(':') + val baseCfiPath = if (cfiOffsetSeparator > 0) { + firstCfiPoint.substring(0, cfiOffsetSeparator) + } else { + firstCfiPoint + } + val charOffset = if (cfiOffsetSeparator > 0 && cfiOffsetSeparator < firstCfiPoint.lastIndex) { + firstCfiPoint.substring(cfiOffsetSeparator + 1).toIntOrNull() ?: 0 + } else { + 0 } val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) if (bestMatch != null) { + val absoluteCharOffset = when (bestMatch) { + is SemanticTextBlock -> { + val localOffset = charOffset.coerceIn(0, bestMatch.text.length) + bestMatch.startCharOffsetInSource + localOffset + } + else -> charOffset.coerceAtLeast(0) + } val locator = Locator( chapterIndex = chapterIndex, blockIndex = bestMatch.blockIndex, - charOffset = charOffset + charOffset = absoluteCharOffset ) Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator") locator @@ -238,14 +301,9 @@ class LocatorConverter( } suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) - var allBlocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, chapterIndex, bookId) @@ -294,14 +352,9 @@ class LocatorConverter( suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) - var blocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - blocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch (_: Exception) { null } - } + var blocks = decodeCachedBlocks(processedChapter, locator.chapterIndex) if (blocks.isNullOrEmpty()) { blocks = processAndCacheChapter(book, locator.chapterIndex, bookId) @@ -313,8 +366,20 @@ class LocatorConverter( val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex) val resultCfi = foundBlock?.cfi?.let { cfi -> - if (locator.charOffset > 0) { - "$cfi:${locator.charOffset}" + val localOffset = when (foundBlock) { + is SemanticTextBlock -> { + val start = foundBlock.startCharOffsetInSource + val end = start + foundBlock.text.length + if (locator.charOffset in start..end) { + locator.charOffset - start + } else { + locator.charOffset + }.coerceIn(0, foundBlock.text.length) + } + else -> locator.charOffset.coerceAtLeast(0) + } + if (localOffset > 0) { + "$cfi:$localOffset" } else { cfi } @@ -363,14 +428,9 @@ class LocatorConverter( } suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) + val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) - var allBlocks: List? = null - if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { - allBlocks = try { - proto.decodeFromByteArray>(processedChapter.contentBlocksProto) - } catch(_: Exception) { null } - } + var allBlocks = decodeCachedBlocks(processedChapter, locator.chapterIndex) if (allBlocks.isNullOrEmpty()) { allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId) @@ -384,7 +444,19 @@ class LocatorConverter( fun traverse(blocks: List): Boolean { for (block in blocks) { if (block.blockIndex == locator.blockIndex) { - offset += locator.charOffset + val absoluteOffset = when (block) { + is SemanticTextBlock -> { + val start = block.startCharOffsetInSource + val end = start + block.text.length + locator.charOffset.takeIf { (start > 0 || offset == 0) && it in start..end } + } + else -> null + } + if (absoluteOffset != null) { + offset = absoluteOffset + } else { + offset += locator.charOffset + } return true } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt b/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt index 09d3c97..a55b214 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/MathMLRenderer.kt @@ -29,6 +29,7 @@ import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient +import com.aryan.reader.BuildConfig import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -182,11 +183,18 @@ class MathMLRenderer(private val context: Context) { val mathMLForJs = job.mathML.replace("`", "\\`") val script = """ (function() { - console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion."); + var mathDiagnosticsEnabled = ${BuildConfig.DEBUG}; + function mathLog() { + if (mathDiagnosticsEnabled) console.log.apply(console, arguments); + } + function mathError() { + if (mathDiagnosticsEnabled) console.error.apply(console, arguments); + } + mathLog("MATH_DIAGNOSTIC: Starting MathML to SVG conversion."); const mathMLContent = `${mathMLForJs}`; - console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent); + mathLog("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent); MathJax.mathml2svgPromise(mathMLContent).then(function (node) { - console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful."); + mathLog("MATH_DIAGNOSTIC: mathml2svgPromise successful."); var svgElement = node.querySelector('svg'); if (svgElement) { svgElement.style.fill = 'currentColor'; @@ -194,15 +202,15 @@ class MathMLRenderer(private val context: Context) { var width = svgElement.getAttribute('width'); var height = svgElement.getAttribute('height'); var viewBox = svgElement.getAttribute('viewBox'); - console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length); - console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput); + mathLog('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length); + mathLog("MATH_DIAGNOSTIC: SVG generated: " + svgOutput); AndroidBridge.onSvgReady(svgOutput); } else { - console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output."); + mathError("MATH_DIAGNOSTIC: SVG element not found in MathJax output."); AndroidBridge.onSvgReady(''); } }).catch((err) => { - console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err); + mathError("MATH_DIAGNOSTIC: MathJax conversion error:", err); AndroidBridge.onSvgReady(''); }); })(); 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 074509f..3c46fff 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -10,32 +10,42 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import android.widget.Toast +import com.aryan.reader.BuildConfig import androidx.compose.ui.unit.isSpecified import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio 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.PaddingValues 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.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.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -52,7 +62,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -64,6 +73,7 @@ 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.composed @@ -98,17 +108,20 @@ import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -152,6 +165,7 @@ import com.aryan.reader.R import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.countWords import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.plainTextCharacterCount import com.aryan.reader.epubreader.HighlightColor import com.aryan.reader.epubreader.PaginatedTextSelectionMenu import com.aryan.reader.epubreader.PaletteManagerDialog @@ -159,6 +173,8 @@ import com.aryan.reader.epubreader.ReaderTextAlign import com.aryan.reader.epubreader.TtsHighlightInfo import com.aryan.reader.epubreader.UserHighlight import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import com.aryan.reader.shared.ReaderBookReplacementPreferences +import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -166,6 +182,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi @@ -195,18 +212,128 @@ data class PaginatedSelection( val textPerBlock: Map = emptyMap() ) +private fun PaginatedSelection.toSharedHighlightLocator( + chapterIndex: Int?, + cfi: String +): SharedReaderLocator { + val startAbsoluteOffset = startBlockCharOffset + startOffset + val endAbsoluteOffset = endBlockCharOffset + endOffset + val rangeStart = minOf(startAbsoluteOffset, endAbsoluteOffset) + val rangeEnd = maxOf(startAbsoluteOffset, endAbsoluteOffset) + return SharedReaderLocator( + chapterIndex = chapterIndex, + pageIndex = startPageIndex, + startOffset = rangeStart, + endOffset = rangeEnd, + blockIndex = startBlockIndex.takeIf { it >= 0 }, + charOffset = rangeStart, + textQuote = text, + cfi = cfi + ) +} + +data class NativeVerticalLocation( + val locator: Locator?, + val chapterIndex: Int?, + val progressPercent: Float, + val compatPageIndex: Int, + val compatTotalPages: Int, + val firstVisibleItemIndex: Int, + val firstVisibleItemScrollOffset: Int, + val firstVisibleItemSize: Int, + val isAtStart: Boolean, + val isAtEnd: Boolean, + val visibleTextRanges: List = emptyList() +) + +data class NativeVerticalVisibleTextRange( + val chapterIndex: Int, + val blockIndex: Int, + val startCharOffset: Int, + val endCharOffset: Int +) + private data class SelectionBlockKey( val pageIndex: Int, val blockIndex: Int, val blockCharOffset: Int ) +private data class NativeVerticalViewportSample( + val firstVisiblePageIndex: Int, + val firstVisiblePageScrollOffset: Int, + val firstVisibleItemSize: Int, + val isAtStart: Boolean, + val isAtEnd: Boolean, + val totalPageCount: Int, + val layoutTick: Int, + val initialScrollComplete: Boolean +) + +private data class AndroidEpubPageContentBounds( + val topPx: Int, + val bottomPx: Int, + val widthPx: Int, + val heightPx: Int, + val pageWidthPx: Int, + val pageHeightPx: Int, + val horizontalPaddingPx: Int, + val verticalPaddingPx: Int +) + +private val AndroidEpubPageContentBounds.pageClipBottomPx: Int + get() = bottomPx + verticalPaddingPx + +private data class NativeVerticalFlowChapter( + val chapterIndex: Int, + val title: String?, + val blocks: List, + val isLoaded: Boolean = true, + val estimatedLocationWeight: Int = 0 +) + +private enum class NativeVerticalFlowItemKind { + BLOCK, + CHAPTER_GAP, + EMPTY_CHAPTER, + UNLOADED_CHAPTER +} + +private data class NativeVerticalFlowItem( + val key: String, + val chapterIndex: Int, + val blockOrdinal: Int, + val block: ContentBlock?, + val kind: NativeVerticalFlowItemKind, + val locationWeight: Int +) + private fun buildSelectionBlockKey( pageIndex: Int, blockIndex: Int, blockCharOffset: Int ): String = "${pageIndex}_${blockIndex}_${blockCharOffset}" +internal fun nativeVerticalInitialChapterPrefetchOrder( + chapterCount: Int, + initialChapter: Int, + forwardCount: Int = 2, + backwardCount: Int = 1 +): List { + if (chapterCount <= 0) return emptyList() + val start = initialChapter.coerceIn(0, chapterCount - 1) + return buildList { + for (offset in 1..forwardCount.coerceAtLeast(0)) { + val chapterIndex = start + offset + if (chapterIndex < chapterCount) add(chapterIndex) + } + for (offset in 1..backwardCount.coerceAtLeast(0)) { + val chapterIndex = start - offset + if (chapterIndex >= 0) add(chapterIndex) + } + } +} + private fun parseSelectionBlockKey(key: String): SelectionBlockKey? { val parts = key.split("_") if (parts.size != 3) return null @@ -244,6 +371,14 @@ private fun getTextBlockCharOffset(block: TextContentBlock): Int = when (block) is ListItemBlock -> block.startCharOffsetInSource } +private fun textBlockLayoutKey( + cfi: String, + pageIndex: Int, + block: TextContentBlock +): String = "${cfi}_${block.blockIndex}_${getTextBlockCharOffset(block)}_${block.content.text.length}_$pageIndex" + +private fun legacyTextBlockLayoutKey(cfi: String, pageIndex: Int): String = "${cfi}_$pageIndex" + private fun headerFontScale(level: Int): Float = when (level) { 1 -> 1.5f 2 -> 1.4f @@ -254,9 +389,14 @@ private fun headerFontScale(level: Int): Float = when (level) { } private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f +private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff" +private const val AndroidEpubCutoffTolerancePx = 1 +private const val AndroidEpubCutoffEdgeProbePx = 2 private const val TAG_STABLE_PAGE_NAV = "StablePageNav" private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" +private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag" private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L +private const val DEBUG_PAGE_TURN_DIAG = false private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String { return text @@ -266,6 +406,17 @@ private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String { .take(maxLength) } +private fun UserHighlight.androidHighlightRenderLabel(): String { + val highlightLocator = this.locator + return "highlightId=$id highlightChapter=$chapterIndex " + + "highlightCfi=${highlightDiagSnippet(cfi, 120)} textLen=${text.length} " + + "text='${highlightDiagSnippet(text)}' " + + "locatorChapter=${highlightLocator.chapterIndex} locatorPage=${highlightLocator.pageIndex} " + + "locatorOffsets=${highlightLocator.startOffset}..${highlightLocator.endOffset} " + + "locatorBlock=${highlightLocator.blockIndex} locatorChar=${highlightLocator.charOffset} " + + "locatorCfi=${highlightDiagSnippet(highlightLocator.cfi.orEmpty(), 120)}" +} + private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float { return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier } @@ -334,12 +485,390 @@ private fun isBlockSelectedOnPage( return afterStart && beforeEnd } +private fun isSelectionBlockKeyInsideSelection( + key: SelectionBlockKey, + selection: PaginatedSelection +): Boolean { + if (key.pageIndex < selection.startPageIndex || key.pageIndex > selection.endPageIndex) return false + if (key.pageIndex > selection.startPageIndex && key.pageIndex < selection.endPageIndex) return true + + val afterStart = if (key.pageIndex == selection.startPageIndex) { + compareBlockPositionsOnPage( + key.blockIndex, + key.blockCharOffset, + selection.startBlockIndex, + selection.startBlockCharOffset + ) >= 0 + } else { + true + } + val beforeEnd = if (key.pageIndex == selection.endPageIndex) { + compareBlockPositionsOnPage( + key.blockIndex, + key.blockCharOffset, + selection.endBlockIndex, + selection.endBlockCharOffset + ) <= 0 + } else { + true + } + + return afterStart && beforeEnd +} + +private data class AttachedSelectionBlock( + val pageIndex: Int, + val layout: TextLayoutResult, + val coords: LayoutCoordinates, + val block: TextContentBlock +) + +private fun attachedSelectionBlocks( + blockLayoutMap: Map>, + pageFilter: (Int) -> Boolean = { true } +): List { + return blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val pageIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + if (!pageFilter(pageIndex)) return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached || block.cfi == null) return@mapNotNull null + AttachedSelectionBlock( + pageIndex = pageIndex, + layout = layout, + coords = coords, + block = block + ) + } + .sortedWith( + compareBy { it.pageIndex } + .thenBy { it.block.blockIndex } + .thenBy { getTextBlockCharOffset(it.block) } + ) + .toList() +} + +private fun visibleSelectedBlocks( + blockLayoutMap: Map>, + selection: PaginatedSelection +): List { + return attachedSelectionBlocks(blockLayoutMap) { pageIndex -> + pageIndex in selection.startPageIndex..selection.endPageIndex + }.filter { blockInfo -> + isBlockSelectedOnPage(blockInfo.block, blockInfo.pageIndex, selection) + } +} + +private fun selectionWindowBounds( + selection: PaginatedSelection, + selectedBlocks: List, + extraBottomPaddingPx: Float = 0f +): Rect { + var minLeft = Float.POSITIVE_INFINITY + var minTop = Float.POSITIVE_INFINITY + var maxRight = Float.NEGATIVE_INFINITY + var maxBottom = Float.NEGATIVE_INFINITY + + selectedBlocks.forEach { blockInfo -> + val textLayout = blockInfo.layout + val coords = blockInfo.coords + val block = blockInfo.block + val currentBlockAbs = getTextBlockCharOffset(block) + val isStartBlockPart = + blockInfo.pageIndex == selection.startPageIndex && + block.blockIndex == selection.startBlockIndex && + currentBlockAbs == selection.startBlockCharOffset + val isEndBlockPart = + blockInfo.pageIndex == selection.endPageIndex && + block.blockIndex == selection.endBlockIndex && + currentBlockAbs == selection.endBlockCharOffset + + val blockStartOffset = if (isStartBlockPart) selection.startOffset else 0 + val blockEndOffset = if (isEndBlockPart) selection.endOffset else textLayout.layoutInput.text.length + + val textLen = textLayout.layoutInput.text.length + val safeStart = blockStartOffset.coerceIn(0, textLen) + val safeEnd = blockEndOffset.coerceIn(safeStart, textLen) + if (safeStart >= safeEnd) return@forEach + + try { + val localBounds = textLayout.getPathForRange(safeStart, safeEnd).getBounds() + val topLeftWin = coords.localToWindow(localBounds.topLeft) + val bottomRightWin = coords.localToWindow(localBounds.bottomRight) + minLeft = minOf(minLeft, topLeftWin.x, bottomRightWin.x) + minTop = minOf(minTop, topLeftWin.y, bottomRightWin.y) + maxRight = maxOf(maxRight, topLeftWin.x, bottomRightWin.x) + maxBottom = maxOf(maxBottom, topLeftWin.y, bottomRightWin.y) + } catch (e: Exception) { + Timber.e(e, "Error calculating exact selection bounds") + } + } + + return if (minTop != Float.POSITIVE_INFINITY && maxBottom != Float.NEGATIVE_INFINITY) { + Rect(minLeft, minTop, maxRight, maxBottom + extraBottomPaddingPx) + } else { + Rect( + selection.rect.left, + selection.rect.top, + selection.rect.right, + selection.rect.bottom + extraBottomPaddingPx + ) + } +} + +private fun findSelectionLayout( + blockLayoutMap: Map>, + cfi: String, + pageIndex: Int, + blockCharOffset: Int +): Triple? { + blockLayoutMap[legacyTextBlockLayoutKey(cfi, pageIndex)]?.takeIf { + getTextBlockCharOffset(it.third) == blockCharOffset + }?.let { return it } + + return blockLayoutMap.entries.firstOrNull { (key, layoutInfo) -> + key.substringAfterLast("_").toIntOrNull() == pageIndex && + layoutInfo.third.cfi == cfi && + getTextBlockCharOffset(layoutInfo.third) == blockCharOffset + }?.value +} + +private fun selectionHandleRootPosition( + selection: PaginatedSelection, + isStart: Boolean, + blockLayoutMap: Map>, + rootCoords: LayoutCoordinates? +): Offset { + val handlePageIndex = if (isStart) selection.startPageIndex else selection.endPageIndex + val selCfi = if (isStart) selection.startBaseCfi else selection.endBaseCfi + val selOffset = if (isStart) selection.startOffset else selection.endOffset + val targetBlockAbs = if (isStart) selection.startBlockCharOffset else selection.endBlockCharOffset + val layoutInfo = findSelectionLayout( + blockLayoutMap = blockLayoutMap, + cfi = selCfi, + pageIndex = handlePageIndex, + blockCharOffset = targetBlockAbs + ) + val root = rootCoords + + if (layoutInfo == null || !layoutInfo.second.isAttached || root == null || !root.isAttached) { + return Offset.Unspecified + } + + return try { + val textLayout = layoutInfo.first + val coords = layoutInfo.second + val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1) + val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length) + val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx) + val line = textLayout.getLineForOffset(safeOffsetForLine) + val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true) + val y = textLayout.getLineBottom(line) + val windowPos = coords.localToWindow(Offset(x, y)) + root.windowToLocal(windowPos) + } catch (_: Exception) { + Offset.Unspecified + } +} + +private fun updatedSelectionForHandleDrag( + selection: PaginatedSelection, + windowPos: Offset, + currentDragHandle: SelectionHandle, + attachedBlocks: List, + blockLayoutMap: Map> +): Pair? { + var activeDragHandle = currentDragHandle + if (attachedBlocks.isEmpty()) return null + + val targetBlockInfo = attachedBlocks.minByOrNull { blockInfo -> + val coords = blockInfo.coords + val rect = Rect(coords.positionInWindow(), coords.size.toSize()) + val dx = maxOf(rect.left - windowPos.x, 0f, windowPos.x - rect.right) + val dy = maxOf(rect.top - windowPos.y, 0f, windowPos.y - rect.bottom) + dx * dx + dy * dy + } ?: return null + + val textLayout = targetBlockInfo.layout + val coords = targetBlockInfo.coords + val block = targetBlockInfo.block + val localPos = coords.windowToLocal(windowPos) + val offset = textLayout.getOffsetForPosition(localPos) + .coerceIn(0, textLayout.layoutInput.text.length) + + val isStartHandle = activeDragHandle == SelectionHandle.START + var newStartIdx = if (isStartHandle) block.blockIndex else selection.startBlockIndex + var newEndIdx = if (isStartHandle) selection.endBlockIndex else block.blockIndex + var newStartOffset = if (isStartHandle) offset else selection.startOffset + var newEndOffset = if (isStartHandle) selection.endOffset else offset + var newStartCfi = if (isStartHandle) block.cfi!! else selection.startBaseCfi + var newEndCfi = if (isStartHandle) selection.endBaseCfi else block.cfi!! + var newStartPageIdx = if (isStartHandle) targetBlockInfo.pageIndex else selection.startPageIndex + var newEndPageIdx = if (isStartHandle) selection.endPageIndex else targetBlockInfo.pageIndex + + val currentBlockAbs = getTextBlockCharOffset(block) + var newStartBlockAbs = if (isStartHandle) currentBlockAbs else selection.startBlockCharOffset + var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else selection.endBlockCharOffset + + val isReversed = when { + newStartPageIdx != newEndPageIdx -> newStartPageIdx > newEndPageIdx + else -> { + val blockCompare = compareBlockPositionsOnPage( + newStartIdx, + newStartBlockAbs, + newEndIdx, + newEndBlockAbs + ) + if (blockCompare != 0) blockCompare > 0 else newStartOffset > newEndOffset + } + } + + if (isReversed) { + newStartPageIdx = newEndPageIdx.also { newEndPageIdx = newStartPageIdx } + newStartIdx = newEndIdx.also { newEndIdx = newStartIdx } + newStartOffset = newEndOffset.also { newEndOffset = newStartOffset } + newStartCfi = newEndCfi.also { newEndCfi = newStartCfi } + newStartBlockAbs = newEndBlockAbs.also { newEndBlockAbs = newStartBlockAbs } + activeDragHandle = if (activeDragHandle == SelectionHandle.START) SelectionHandle.END else SelectionHandle.START + } + + if ( + newStartPageIdx == selection.startPageIndex && + newEndPageIdx == selection.endPageIndex && + newStartIdx == selection.startBlockIndex && + newEndIdx == selection.endBlockIndex && + newStartOffset == selection.startOffset && + newEndOffset == selection.endOffset + ) { + return null + } + + val tentativeSelection = selection.copy( + startBlockIndex = newStartIdx, + endBlockIndex = newEndIdx, + startBaseCfi = newStartCfi, + endBaseCfi = newEndCfi, + startOffset = newStartOffset, + endOffset = newEndOffset, + startPageIndex = newStartPageIdx, + endPageIndex = newEndPageIdx, + startBlockCharOffset = newStartBlockAbs, + endBlockCharOffset = newEndBlockAbs + ) + + val relevantBlocks = attachedBlocks + .filter { isBlockSelectedOnPage(it.block, it.pageIndex, tentativeSelection) } + .sortedWith( + compareBy { it.pageIndex } + .thenBy { it.block.blockIndex } + .thenBy { getTextBlockCharOffset(it.block) } + ) + + val attachedKeys = attachedBlocks.map { blockInfo -> + buildSelectionBlockKey( + pageIndex = blockInfo.pageIndex, + blockIndex = blockInfo.block.blockIndex, + blockCharOffset = getTextBlockCharOffset(blockInfo.block) + ) + }.toSet() + val newTextPerBlock = selection.textPerBlock.toMutableMap() + newTextPerBlock.keys.removeAll { keyStr -> + val key = parseSelectionBlockKey(keyStr) + keyStr in attachedKeys || + (key != null && !isSelectionBlockKeyInsideSelection(key, tentativeSelection)) + } + + for (blockInfo in relevantBlocks) { + val txt = blockInfo.block.content.text + val blockAbs = getTextBlockCharOffset(blockInfo.block) + val isStartBlockPart = + blockInfo.pageIndex == newStartPageIdx && + blockInfo.block.blockIndex == newStartIdx && + blockAbs == newStartBlockAbs + val isEndBlockPart = + blockInfo.pageIndex == newEndPageIdx && + blockInfo.block.blockIndex == newEndIdx && + blockAbs == newEndBlockAbs + + val start = if (isStartBlockPart) newStartOffset else 0 + val end = if (isEndBlockPart) newEndOffset else txt.length + val safeStart = start.coerceIn(0, txt.length) + val safeEnd = end.coerceIn(safeStart, txt.length) + val key = buildSelectionBlockKey( + pageIndex = blockInfo.pageIndex, + blockIndex = blockInfo.block.blockIndex, + blockCharOffset = blockAbs + ) + + if (safeStart < safeEnd) { + newTextPerBlock[key] = txt.substring(safeStart, safeEnd) + } else { + newTextPerBlock.remove(key) + } + } + + val newText = newTextPerBlock.entries + .sortedWith { first, second -> compareSelectionBlockKeys(first.key, second.key) } + .joinToString(" ") { it.value } + .ifEmpty { selection.text } + + val selectionWithText = tentativeSelection.copy( + text = newText, + textPerBlock = newTextPerBlock + ) + + val sLayout = findSelectionLayout(blockLayoutMap, newStartCfi, newStartPageIdx, newStartBlockAbs) + val eLayout = findSelectionLayout(blockLayoutMap, newEndCfi, newEndPageIdx, newEndBlockAbs) + val newRect = if (sLayout != null && eLayout != null && sLayout.second.isAttached && eLayout.second.isAttached) { + val sMaxIdx = maxOf(0, sLayout.first.layoutInput.text.length - 1) + val eMaxIdx = maxOf(0, eLayout.first.layoutInput.text.length - 1) + try { + val sRectLocal = sLayout.first.getBoundingBox(newStartOffset.coerceIn(0, sMaxIdx)) + val sRectWin = Rect( + sLayout.second.localToWindow(sRectLocal.topLeft), + sLayout.second.localToWindow(sRectLocal.bottomRight) + ) + val eRectLocal = eLayout.first.getBoundingBox((newEndOffset - 1).coerceIn(0, eMaxIdx)) + val eRectWin = Rect( + eLayout.second.localToWindow(eRectLocal.topLeft), + eLayout.second.localToWindow(eRectLocal.bottomRight) + ) + Rect( + minOf(sRectWin.left, eRectWin.left), + sRectWin.top, + maxOf(sRectWin.right, eRectWin.right), + eRectWin.bottom + ) + } catch (_: Exception) { + selectionWindowBounds(selectionWithText, relevantBlocks) + } + } else { + selectionWindowBounds(selectionWithText, relevantBlocks) + } + + return selectionWithText.copy(rect = newRect) to activeDragHandle +} + internal fun highlightsForPaginatedPage( pageChapterIndex: Int?, userHighlights: List ): List { - if (pageChapterIndex == null) return emptyList() - return userHighlights.filter { it.chapterIndex == pageChapterIndex } + if (pageChapterIndex == null) { + if (userHighlights.isNotEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "page_scope_skip reason=null_page_chapter inputHighlightCount=${userHighlights.size}" + ) + } + return emptyList() + } + val scoped = userHighlights.filter { it.chapterIndex == pageChapterIndex } + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "page_scope pageChapter=$pageChapterIndex inputHighlightCount=${userHighlights.size} " + + "scopedHighlightCount=${scoped.size} scopedIds=${scoped.map { it.id }}" + ) + return scoped } class ReactiveBlockMap( @@ -361,6 +890,526 @@ class ReactiveBlockMap( tick++ delegate.clear() } + + fun pruneDetached() { + val detachedKeys = delegate + .filterValues { (_, coords, _) -> !coords.isAttached } + .keys + .toList() + if (detachedKeys.isEmpty()) return + detachedKeys.forEach { delegate.remove(it) } + tick++ + } +} + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun estimateNativeVerticalCompatPage( + book: EpubBook, + paginator: BookPaginator, + locator: Locator?, + fallbackPage: Int +): Int { + if (locator == null) return fallbackPage + val chapterStart = paginator.chapterStartPageIndices[locator.chapterIndex] ?: return fallbackPage + val chapterPageCount = paginator.chapterPageCounts[locator.chapterIndex] ?: 1 + if (chapterPageCount <= 1) return chapterStart + + val chapterChars = book.chaptersForPagination + .getOrNull(locator.chapterIndex) + ?.plainTextCharacterCount() + ?.coerceAtLeast(1) + ?: return fallbackPage + val ratio = locator.charOffset.toFloat().coerceAtLeast(0f) / chapterChars.toFloat() + val pageInChapter = (ratio.coerceIn(0f, 1f) * (chapterPageCount - 1)).roundToInt() + return chapterStart + pageInChapter +} + +private fun estimateNativeVerticalProgressPercent( + book: EpubBook, + locator: Locator? +): Float? { + if (locator == null) return null + val totalChars = book.chaptersForPagination + .sumOf { it.plainTextCharacterCount().toLong() } + .takeIf { it > 0L } + ?: return null + val completedChars = book.chaptersForPagination + .take(locator.chapterIndex) + .sumOf { it.plainTextCharacterCount().toLong() } + val chapterChars = book.chaptersForPagination + .getOrNull(locator.chapterIndex) + ?.plainTextCharacterCount() + ?.toLong() + ?: 0L + val chapterOffset = locator.charOffset + .toLong() + .coerceIn(0L, chapterChars.coerceAtLeast(0L)) + return (((completedChars + chapterOffset).toDouble() / totalChars.toDouble()) * 100.0) + .toFloat() + .coerceIn(0f, 100f) +} + +private fun locatorForNativeVerticalFlowBlock(chapterIndex: Int, block: ContentBlock): Locator { + val firstTextBlock = listOf(block) + .extractTextBlocks() + .firstOrNull { it.content.text.isNotBlank() } + ?: listOf(block).extractTextBlocks().firstOrNull() + + return if (firstTextBlock != null) { + Locator( + chapterIndex = chapterIndex, + blockIndex = firstTextBlock.blockIndex, + charOffset = getTextBlockCharOffset(firstTextBlock) + ) + } else { + Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = 0 + ) + } +} + +private fun findNativeVerticalFlowTextBlockForLocator( + chapters: List, + locator: Locator +): TextContentBlock? { + val blocks = chapters.firstOrNull { it.chapterIndex == locator.chapterIndex }?.blocks + ?: return null + val textBlocks = blocks.extractTextBlocks() + return textBlocks.firstOrNull { block -> + val start = getTextBlockCharOffset(block) + val end = start + block.content.text.length + block.blockIndex == locator.blockIndex && locator.charOffset in start..end + } ?: textBlocks.firstOrNull { it.blockIndex >= locator.blockIndex } + ?: textBlocks.firstOrNull() +} + +private fun nativeVerticalFlowBlockMatchesLocator(block: ContentBlock, locator: Locator): Boolean { + if (block.blockIndex == locator.blockIndex) return true + return when (block) { + is FlexContainerBlock -> block.children.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + is TableBlock -> block.rows.flatten().any { cell -> + cell.content.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + } + is WrappingContentBlock -> + nativeVerticalFlowBlockMatchesLocator(block.floatedImage, locator) || + block.paragraphsToWrap.any { nativeVerticalFlowBlockMatchesLocator(it, locator) } + else -> false + } +} + +private fun nativeVerticalFlowItemWeight(block: ContentBlock?): Int { + if (block == null) return 0 + val textLength = listOf(block).extractTextBlocks() + .sumOf { it.content.text.length } + return textLength.coerceAtLeast( + when (block) { + is ImageBlock -> 250 + is MathBlock -> 80 + is SpacerBlock -> 1 + else -> 24 + } + ) +} + +internal fun nativeVerticalCompatPageForProgress(progressPercent: Float, totalPageCount: Int): Int { + if (totalPageCount <= 1) return 0 + return ((progressPercent.coerceIn(0f, 100f) / 100f) * (totalPageCount - 1)) + .roundToInt() + .coerceIn(0, totalPageCount - 1) +} + +internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount: Int): Float { + if (totalPageCount <= 1) return 0f + return (pageIndex.coerceIn(0, totalPageCount - 1).toFloat() / (totalPageCount - 1).toFloat() * 100f) + .coerceIn(0f, 100f) +} + +internal fun nativeVerticalProgressToItemIndex( + itemWeights: List, + progressPercent: Float +): Int? { + if (itemWeights.isEmpty()) return null + val totalWeight = itemWeights.sumOf { it.coerceAtLeast(0) } + if (totalWeight <= 0) { + return ((progressPercent.coerceIn(0f, 100f) / 100f) * (itemWeights.size - 1)) + .roundToInt() + .coerceIn(0, itemWeights.lastIndex) + } + + val targetWeight = totalWeight * (progressPercent.coerceIn(0f, 100f) / 100f) + var accumulated = 0 + var lastWeightedIndex = 0 + itemWeights.forEachIndexed { index, rawWeight -> + val weight = rawWeight.coerceAtLeast(0) + if (weight <= 0) return@forEachIndexed + lastWeightedIndex = index + val next = accumulated + weight + if (targetWeight <= next || index == itemWeights.lastIndex) { + return index + } + accumulated = next + } + return lastWeightedIndex +} + +private fun buildNativeVerticalFlowItems( + chapters: List +): List { + return chapters.flatMapIndexed { chapterOrdinal, chapter -> + val boundary = if (chapterOrdinal > 0) { + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-gap", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -2, + block = null, + kind = NativeVerticalFlowItemKind.CHAPTER_GAP, + locationWeight = 0 + ) + ) + } else { + emptyList() + } + if (!chapter.isLoaded) { + boundary + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-unloaded", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -1, + block = null, + kind = NativeVerticalFlowItemKind.UNLOADED_CHAPTER, + locationWeight = chapter.estimatedLocationWeight.coerceAtLeast(24) + ) + ) + } else if (chapter.blocks.isEmpty()) { + boundary + listOf( + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-empty", + chapterIndex = chapter.chapterIndex, + blockOrdinal = -1, + block = null, + kind = NativeVerticalFlowItemKind.EMPTY_CHAPTER, + locationWeight = 0 + ) + ) + } else { + boundary + chapter.blocks.mapIndexed { ordinal, block -> + NativeVerticalFlowItem( + key = "chapter-${chapter.chapterIndex}-block-$ordinal-${block.blockIndex}", + chapterIndex = chapter.chapterIndex, + blockOrdinal = ordinal, + block = block, + kind = NativeVerticalFlowItemKind.BLOCK, + locationWeight = nativeVerticalFlowItemWeight(block) + ) + } + } + } +} + +private fun findNativeVerticalFlowItemIndexForProgress( + items: List, + progressPercent: Float +): Int? { + return nativeVerticalProgressToItemIndex( + itemWeights = items.map { it.locationWeight }, + progressPercent = progressPercent + ) +} + +private fun estimateNativeVerticalScrollProgressPercent( + items: List, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, + firstVisibleItemSize: Int +): Float? { + if (items.isEmpty()) return null + val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null + val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex) + val completedWeight = items + .take(safeIndex) + .sumOf { it.locationWeight } + val currentItem = items[safeIndex] + val currentFraction = if (firstVisibleItemSize > 0) { + (firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction) + return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0) + .toFloat() + .coerceIn(0f, 100f) +} + +private fun findNativeVerticalFlowItemIndexForLocator( + items: List, + chapters: List, + locator: Locator +): Int? { + val targetTextBlock = findNativeVerticalFlowTextBlockForLocator(chapters, locator) + if (targetTextBlock != null && targetTextBlock.blockIndex == locator.blockIndex) { + val exactIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { block -> + listOf(block).extractTextBlocks().any { textBlock -> + textBlock.cfi == targetTextBlock.cfi || + ( + textBlock.blockIndex == targetTextBlock.blockIndex && + getTextBlockCharOffset(textBlock) == getTextBlockCharOffset(targetTextBlock) + ) + } + } == true + } + if (exactIndex >= 0) return exactIndex + } + + val matchingContainerIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { nativeVerticalFlowBlockMatchesLocator(it, locator) } == true + } + if (matchingContainerIndex >= 0) return matchingContainerIndex + + val blockIndex = items.indexOfFirst { item -> + item.chapterIndex == locator.chapterIndex && + (item.block?.blockIndex ?: Int.MAX_VALUE) >= locator.blockIndex + } + if (blockIndex >= 0) return blockIndex + + return items.indexOfFirst { it.chapterIndex == locator.chapterIndex } + .takeIf { it >= 0 } +} + +private fun locatorForNativeVerticalFlowItem(item: NativeVerticalFlowItem): Locator? { + return item.block?.let { locatorForNativeVerticalFlowBlock(item.chapterIndex, it) } + ?: Locator(item.chapterIndex, 0, 0) +} + +private fun resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds: Rect, + chapterLayoutMap: Map, + flowItems: List, + flowItemLayoutMap: Map, + blockLayoutMap: Map>, + chapters: List, + locator: Locator, + allowChapterFallback: Boolean = true +): Float? { + if (rootWindowBounds == Rect.Zero) return null + + val targetTextBlock = findNativeVerticalFlowTextBlockForLocator(chapters, locator) + if (targetTextBlock?.cfi != null && targetTextBlock.blockIndex == locator.blockIndex) { + val layoutInfo = findSelectionLayout( + blockLayoutMap = blockLayoutMap, + cfi = targetTextBlock.cfi!!, + pageIndex = locator.chapterIndex, + blockCharOffset = getTextBlockCharOffset(targetTextBlock) + ) + if (layoutInfo != null) { + val (layout, coords, block) = layoutInfo + if (coords.isAttached && layout.lineCount > 0) { + val relativeOffset = (locator.charOffset - getTextBlockCharOffset(block)) + .coerceIn(0, block.content.text.length) + val lineIndex = runCatching { layout.getLineForOffset(relativeOffset) } + .getOrDefault(0) + .coerceIn(0, layout.lineCount - 1) + val localY = runCatching { layout.getLineTop(lineIndex) } + .getOrDefault(0f) + val targetWindowY = coords.localToWindow(Offset(0f, localY)).y + return targetWindowY - rootWindowBounds.top + } + } + } + + flowItems.firstOrNull { item -> + item.chapterIndex == locator.chapterIndex && + item.block?.let { nativeVerticalFlowBlockMatchesLocator(it, locator) } == true + }?.let { item -> + val coords = flowItemLayoutMap[item.key] + if (coords?.isAttached == true) { + return coords.positionInWindow().y - rootWindowBounds.top + } + } + + if (!allowChapterFallback) return null + + val chapterCoords = chapterLayoutMap[locator.chapterIndex] + if (chapterCoords?.isAttached == true) { + return chapterCoords.positionInWindow().y - rootWindowBounds.top + } + + return null +} + +private fun resolveNativeVerticalFlowVisibleLocator( + rootWindowBounds: Rect, + blockLayoutMap: Map> +): Locator? { + if (rootWindowBounds == Rect.Zero) return null + val viewportTop = rootWindowBounds.top + 8f + val viewportBottom = rootWindowBounds.bottom - 8f + + val visible = blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val chapterIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached) return@mapNotNull null + val bounds = Rect(coords.positionInWindow(), coords.size.toSize()) + if (bounds.bottom <= viewportTop || bounds.top >= viewportBottom) { + null + } else { + Triple(chapterIndex, bounds, layoutInfo) + } + } + .sortedBy { it.second.top } + .firstOrNull { it.second.bottom > viewportTop } + ?: return null + + val chapterIndex = visible.first + val bounds = visible.second + val (layout, _, block) = visible.third + val blockStartOffset = getTextBlockCharOffset(block) + if (layout.lineCount <= 0) { + return Locator(chapterIndex, block.blockIndex, blockStartOffset) + } + + val maxLayoutY = (layout.size.height - 1).coerceAtLeast(0).toFloat() + val localY = (viewportTop - bounds.top).coerceIn(0f, maxLayoutY) + val lineIndex = runCatching { layout.getLineForVerticalPosition(localY) } + .getOrDefault(0) + .coerceIn(0, layout.lineCount - 1) + val relativeOffset = runCatching { layout.getLineStart(lineIndex) } + .getOrDefault(0) + .coerceIn(0, block.content.text.length) + + return Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = blockStartOffset + relativeOffset + ) +} + +private fun resolveNativeVerticalVisibleTextRanges( + rootWindowBounds: Rect, + blockLayoutMap: Map> +): List { + if (rootWindowBounds == Rect.Zero) return emptyList() + val viewportTop = rootWindowBounds.top + 8f + val viewportBottom = rootWindowBounds.bottom - 8f + + return blockLayoutMap.entries + .asSequence() + .mapNotNull { (key, layoutInfo) -> + val chapterIndex = key.substringAfterLast("_").toIntOrNull() + ?: return@mapNotNull null + val (layout, coords, block) = layoutInfo + if (!coords.isAttached) return@mapNotNull null + val bounds = Rect(coords.positionInWindow(), coords.size.toSize()) + if (bounds.bottom <= viewportTop || bounds.top >= viewportBottom) { + null + } else { + val blockStart = getTextBlockCharOffset(block) + val visibleTopInText = (viewportTop - bounds.top).coerceAtLeast(0f) + val visibleBottomInText = (viewportBottom - bounds.top).coerceAtMost(bounds.height) + var firstVisibleOffset: Int? = null + var lastVisibleOffset: Int? = null + + for (lineIndex in 0 until layout.lineCount) { + val lineTop = runCatching { layout.getLineTop(lineIndex) }.getOrDefault(0f) + val lineBottom = runCatching { layout.getLineBottom(lineIndex) }.getOrDefault(lineTop) + if (lineBottom < visibleTopInText || lineTop > visibleBottomInText) continue + + val lineStart = runCatching { layout.getLineStart(lineIndex) }.getOrDefault(0) + .coerceIn(0, block.content.length) + val lineEnd = runCatching { layout.getLineEnd(lineIndex, visibleEnd = true) }.getOrDefault(lineStart) + .coerceIn(lineStart, block.content.length) + firstVisibleOffset = minOf(firstVisibleOffset ?: lineStart, lineStart) + lastVisibleOffset = maxOf(lastVisibleOffset ?: lineEnd, lineEnd) + } + + val start = blockStart + (firstVisibleOffset ?: 0) + val end = blockStart + (lastVisibleOffset ?: block.content.text.length) + NativeVerticalVisibleTextRange( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + startCharOffset = start, + endCharOffset = end + ) + } + } + .toList() +} + +private fun resolveReaderFootnoteHtml( + book: EpubBook, + currentChapterPath: String, + href: String +): String? { + var isFootnote = href.contains("footnote", ignoreCase = true) || + href.contains("fn", ignoreCase = true) + var footnoteHtml: String? = null + + 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(currentChapterPath).resolve(pathPart).normalize().path + } catch (_: Exception) { + null + } + } + + if (targetPath != null) { + val targetChapter = book.chaptersForPagination.find { + try { + URI(it.absPath).normalize().path == targetPath + } catch (_: Exception) { + false + } + } + + 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) { + isFootnote = true + footnoteHtml = noteEl.html() + } + } + } + } + } + } + + return footnoteHtml.takeIf { isFootnote && !it.isNullOrBlank() } } data class PendingCrossPageSelection(val fromPageIndex: Int) @@ -478,6 +1527,319 @@ private fun highlightQueryInText( } } +internal fun AnnotatedString.readerUrlAnnotationAtOffset(offset: Int): String? { + if (length == 0) return null + + val safeOffset = offset.coerceIn(0, length) + getStringAnnotations("URL", safeOffset, safeOffset).firstOrNull()?.let { return it.item } + + if (safeOffset < length) { + getStringAnnotations("URL", safeOffset, safeOffset + 1).firstOrNull()?.let { return it.item } + } + + if (safeOffset > 0) { + getStringAnnotations("URL", safeOffset - 1, safeOffset).firstOrNull()?.let { return it.item } + } + + return null +} + +internal fun String.isReaderExternalHref(): Boolean { + val href = trim() + if (href.startsWith("//")) return true + + val schemeEnd = href.indexOf(':') + if (schemeEnd <= 0) return false + + val scheme = href.substring(0, schemeEnd) + if (!scheme.first().isLetter()) return false + if (!scheme.all { it.isLetterOrDigit() || it == '+' || it == '-' || it == '.' }) return false + + return scheme.lowercase() in setOf("http", "https", "mailto", "tel", "sms", "geo") +} + +private fun String.readerExternalHrefForDisplay(): String { + val href = trim() + return if (href.startsWith("//")) "https:$href" else href +} + +private const val READER_LINK_HIT_SLOP_PX = 2f + +internal fun AnnotatedString.readerUrlAnnotationAtPosition( + layout: TextLayoutResult, + position: Offset, + textStartOffset: Int = 0 +): String? { + if (length == 0 || layout.lineCount == 0) return null + + val localTextLength = layout.layoutInput.text.length + if (localTextLength == 0) return null + + val lineIndex = layout.getLineForVerticalPosition(position.y) + if (lineIndex !in 0 until layout.lineCount) return null + + val lineTop = layout.getLineTop(lineIndex) + val lineBottom = layout.getLineBottom(lineIndex) + if ( + position.y < lineTop - READER_LINK_HIT_SLOP_PX || + position.y > lineBottom + READER_LINK_HIT_SLOP_PX + ) { + return null + } + + val localLineStart = layout.getLineStart(lineIndex) + val localLineEnd = layout.getLineEnd(lineIndex, visibleEnd = true) + if (localLineStart >= localLineEnd) return null + + val globalLineStart = (textStartOffset + localLineStart).coerceIn(0, length) + val globalLineEnd = (textStartOffset + localLineEnd).coerceIn(globalLineStart, length) + if (globalLineStart >= globalLineEnd) return null + + return getStringAnnotations("URL", globalLineStart, globalLineEnd) + .firstOrNull { annotation -> + if (annotation.item.isBlank()) return@firstOrNull false + + val localStart = (annotation.start - textStartOffset).coerceIn(0, localTextLength) + val localEnd = (annotation.end - textStartOffset).coerceIn(0, localTextLength) + val segmentStart = maxOf(localStart, localLineStart) + val segmentEnd = minOf(localEnd, localLineEnd) + layout.readerTextRangeContainsPosition(segmentStart, segmentEnd, position) + } + ?.item +} + +private fun TextLayoutResult.readerTextRangeContainsPosition( + start: Int, + endExclusive: Int, + position: Offset +): Boolean { + val textLength = layoutInput.text.length + val safeStart = start.coerceIn(0, textLength) + val safeEnd = endExclusive.coerceIn(safeStart, textLength) + if (safeStart >= safeEnd) return false + + val lineIndex = getLineForVerticalPosition(position.y) + val startLine = getLineForOffset(safeStart) + val endLine = getLineForOffset((safeEnd - 1).coerceAtLeast(safeStart)) + if (lineIndex !in startLine..endLine) return false + + val lineStart = getLineStart(lineIndex) + val lineEnd = getLineEnd(lineIndex, visibleEnd = true) + val segmentStart = maxOf(safeStart, lineStart) + val segmentEnd = minOf(safeEnd, lineEnd) + if (segmentStart >= segmentEnd) return false + + var left = Float.POSITIVE_INFINITY + var right = Float.NEGATIVE_INFINITY + for (offset in segmentStart until segmentEnd) { + val box = getBoundingBox(offset) + left = minOf(left, box.left, box.right) + right = maxOf(right, box.left, box.right) + } + if (left == Float.POSITIVE_INFINITY || right == Float.NEGATIVE_INFINITY) return false + + return position.x >= left - READER_LINK_HIT_SLOP_PX && + position.x <= right + READER_LINK_HIT_SLOP_PX +} + +private data class ReaderPageLinkHit( + val href: String, + val blockIndex: Int, + val cfi: String? +) + +private fun ReactiveBlockMap.readerLinkAtPagePosition( + pageCoordinates: LayoutCoordinates, + pageIndex: Int, + position: Offset +): ReaderPageLinkHit? { + val windowPosition = pageCoordinates.localToWindow(position) + return entries.firstNotNullOfOrNull { (key, value) -> + if (!key.endsWith("_$pageIndex")) return@firstNotNullOfOrNull null + + val (layout, coordinates, block) = value + if (!coordinates.isAttached) return@firstNotNullOfOrNull null + + val localPosition = coordinates.windowToLocal(windowPosition) + if ( + localPosition.x < 0f || + localPosition.y < 0f || + localPosition.x > layout.size.width.toFloat() || + localPosition.y > layout.size.height.toFloat() + ) { + return@firstNotNullOfOrNull null + } + + layout.layoutInput.text + .readerUrlAnnotationAtPosition(layout, localPosition) + ?.let { href -> + ReaderPageLinkHit( + href = href, + blockIndex = block.blockIndex, + cfi = block.cfi + ) + } + } +} + +private suspend fun AwaitPointerEventScope.awaitReaderLinkTap( + source: String, + urlAtPosition: (Offset) -> String?, + touchSlop: Float, + onLinkClick: (String) -> Unit +) { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (down.isConsumed) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).v( + "tap_down_skip_consumed source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()}" + ) + return + } + val url = urlAtPosition(down.position) + if (url == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).v( + "tap_down_miss source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()}" + ) + return + } + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_down_hit source=$source x=${down.position.x.roundToInt()} y=${down.position.y.roundToInt()} " + + "href=${url.readerLinkDiagPreview()}" + ) + down.consume() + + var movedOutsideTapSlop = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: continue + val dx = change.position.x - down.position.x + val dy = change.position.y - down.position.y + if (sqrt(dx * dx + dy * dy) > touchSlop) { + movedOutsideTapSlop = true + } + + if (!change.pressed) { + if (!movedOutsideTapSlop) { + change.consume() + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_up_open source=$source href=${url.readerLinkDiagPreview()}" + ) + onLinkClick(url) + } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "tap_cancel_slop source=$source href=${url.readerLinkDiagPreview()} " + + "dx=${dx.roundToInt()} dy=${dy.roundToInt()} slop=${touchSlop.roundToInt()}" + ) + } + break + } + + if (!movedOutsideTapSlop) { + change.consume() + } + } +} + +private fun AnnotatedString.withReaderLinkDisplayStyle( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): AnnotatedString { + val urls = getStringAnnotations("URL", 0, length) + if (urls.isEmpty()) return this + + val linkStyle = readerLinkSpanStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + + return buildAnnotatedString { + append(this@withReaderLinkDisplayStyle) + urls.forEach { range -> + addStyle(linkStyle, range.start, range.end) + } + } +} + +@Composable +private fun LinkAwareText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + onLinkClick: (String) -> Unit, + onGeneralTap: (Offset) -> Unit +) { + var layoutResult by remember { mutableStateOf(null) } + val viewConfiguration = LocalViewConfiguration.current + val latestLayoutResult = rememberUpdatedState(layoutResult) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) + val displayText = remember(text, isDarkTheme, themeBackgroundColor, themeTextColor, style.color) { + text.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = style.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } + LaunchedEffect(displayText) { + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "compose_text source=LinkAwareText " + displayText.readerAnnotatedLinkDiagSummary() + ) + } + } + + Text( + text = displayText, + style = style, + modifier = modifier + .pointerInput(displayText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "LinkAwareText", + urlAtPosition = { offset -> + latestLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(displayText) { + detectTapGestures( + onTap = { offset -> + val url = latestLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=LinkAwareText href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + } else { + latestOnGeneralTap.value(offset) + } + } + ) + }, + onTextLayout = { + layoutResult = it + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_text source=LinkAwareText size=${it.size.width}x${it.size.height} " + + "lines=${it.lineCount} " + displayText.readerAnnotatedLinkDiagSummary() + ) + } + } + ) +} + private fun computeImageRenderSizePx( block: ImageBlock, density: Density, @@ -532,6 +1894,15 @@ private fun imageBlockContentAlignment(style: BlockStyle): Alignment { } } +private fun imageContentScale(style: BlockStyle): ContentScale { + return when (style.objectFit) { + "cover" -> ContentScale.Crop + "fill" -> ContentScale.FillBounds + "contain", "scale-down" -> ContentScale.Fit + else -> ContentScale.Fit + } +} + private fun tableCellImageModifier( block: ImageBlock, density: Density, @@ -578,7 +1949,12 @@ private fun WrappingContentLayout( searchQuery: String, ttsHighlightInfo: TtsHighlightInfo?, searchHighlightColor: Color, - ttsHighlightColor: Color + ttsHighlightColor: Color, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + onLinkClick: (String) -> Unit, + onGeneralTap: (Offset) -> Unit ) { val textMeasurer = rememberTextMeasurer() val fullText = remember(block.paragraphsToWrap, searchQuery, ttsHighlightInfo) { @@ -614,6 +1990,13 @@ private fun WrappingContentLayout( } } } + val displayFullText = remember(fullText, isDarkTheme, themeBackgroundColor, themeTextColor, textStyle.color) { + fullText.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = textStyle.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } val (paragraphStartOffsets, paragraphEndOffsetMap) = remember(block.paragraphsToWrap) { val starts = mutableSetOf() val endMap = mutableMapOf() @@ -629,22 +2012,85 @@ private fun WrappingContentLayout( starts to endMap } val density = LocalDensity.current + val viewConfiguration = LocalViewConfiguration.current var textLayouts by remember { - mutableStateOf>>(emptyList()) + mutableStateOf>>(emptyList()) } var totalHeight by remember { mutableIntStateOf(0) } + val latestTextLayouts = rememberUpdatedState(textLayouts) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) Layout(content = { AsyncImage( model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(), contentDescription = block.floatedImage.altText, - contentScale = ContentScale.Fit + contentScale = imageContentScale(block.floatedImage.style) ) - }, modifier = modifier.drawBehind { - textLayouts.forEach { (layout, offset) -> - drawText(layout, topLeft = offset) + }, modifier = modifier + .drawBehind { + textLayouts.forEach { (layout, offset, _) -> + drawText(layout, topLeft = offset) + } } - }) { measurables, constraints -> + .pointerInput(displayFullText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "WrappingContentLayout:block=${block.blockIndex}", + urlAtPosition = { offset -> + latestTextLayouts.value.firstNotNullOfOrNull { (layout, topLeft, textStartOffset) -> + val localOffset = Offset(offset.x - topLeft.x, offset.y - topLeft.y) + if ( + localOffset.x >= 0f && + localOffset.y >= 0f && + localOffset.x <= layout.size.width.toFloat() && + localOffset.y <= layout.size.height.toFloat() + ) { + displayFullText.readerUrlAnnotationAtPosition( + layout = layout, + position = localOffset, + textStartOffset = textStartOffset + ) + } else { + null + } + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(displayFullText) { + detectTapGestures( + onTap = { offset -> + for ((layout, topLeft, textStartOffset) in latestTextLayouts.value) { + val localOffset = Offset(offset.x - topLeft.x, offset.y - topLeft.y) + if ( + localOffset.x >= 0f && + localOffset.y >= 0f && + localOffset.x <= layout.size.width.toFloat() && + localOffset.y <= layout.size.height.toFloat() + ) { + val url = displayFullText.readerUrlAnnotationAtPosition( + layout = layout, + position = localOffset, + textStartOffset = textStartOffset + ) + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=WrappingContentLayout:block=${block.blockIndex} " + + "href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + return@detectTapGestures + } + } + } + latestOnGeneralTap.value(offset) + } + ) + }) { measurables, constraints -> val (imageRenderWidthPx, imageRenderHeightPx) = run { computeImageRenderSizePx( block = block.floatedImage, @@ -669,9 +2115,9 @@ private fun WrappingContentLayout( var currentY = 0f var textOffset = 0 - val layouts = mutableListOf>() + val layouts = mutableListOf>() - while (textOffset < fullText.length) { + while (textOffset < displayFullText.length) { val isBesideImage = currentY < effectiveImageHeight val floatLeft = block.floatedImage.style.float == "left" @@ -684,7 +2130,7 @@ private fun WrappingContentLayout( if (currentMaxWidth <= 0) break val lineConstraints = constraints.copy(minWidth = 0, maxWidth = currentMaxWidth) - val remainingText = fullText.subSequence(textOffset, fullText.length) + val remainingText = displayFullText.subSequence(textOffset, displayFullText.length) val styleForMeasure = remainingText.spanStyles.firstOrNull { it.item.fontFamily != null }?.item?.fontFamily?.let { @@ -727,7 +2173,7 @@ private fun WrappingContentLayout( ) val xOffset = if (isBesideImage && floatLeft) effectiveImageWidth.toFloat() else 0f - layouts.add(lineLayout to Offset(xOffset, currentY)) + layouts.add(Triple(lineLayout, Offset(xOffset, currentY), textOffset)) currentY += lineLayout.size.height val endOfLineVisibleCharIndex = textOffset + firstLineEndOffset - 1 @@ -745,12 +2191,18 @@ private fun WrappingContentLayout( currentY += gap } textOffset += firstLineEndOffset - while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) { + while (textOffset < displayFullText.length && displayFullText[textOffset].isWhitespace()) { textOffset++ } } textLayouts = layouts totalHeight = maxOf(currentY, effectiveImageHeight.toFloat()).roundToInt() + if (displayFullText.getStringAnnotations("URL", 0, displayFullText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_wrapping block=${block.blockIndex} layouts=${layouts.size} totalHeight=$totalHeight " + + displayFullText.readerAnnotatedLinkDiagSummary() + ) + } layout(constraints.maxWidth, totalHeight) { if (imagePlacable != null) { val imageX = if (block.floatedImage.style.float == "left") 0 @@ -783,6 +2235,8 @@ fun PaginatedReaderScreen( verticalMarginMultiplier: Float, fontFamily: FontFamily, textAlign: ReaderTextAlign, + bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + bookReplacementFileId: String? = bookId, ttsHighlightInfo: TtsHighlightInfo?, initialChapterIndexInBook: Int?, fallbackLocatorForReconfiguration: Locator? = null, @@ -802,9 +2256,9 @@ fun PaginatedReaderScreen( onStartTtsFromSelection: (String, Int) -> Unit, onNoteRequested: (String?) -> Unit, onFootnoteRequested: (String) -> Unit, - onInternalLinkNavigated: (Int) -> Unit = {}, + onInternalLinkNavigated: (Int, Locator?) -> Unit = { _, _ -> }, userHighlights: List, - onHighlightCreated: (String, String, String) -> Unit, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, onHighlightDeleted: (String) -> Unit, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, @@ -838,6 +2292,9 @@ fun PaginatedReaderScreen( val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor) val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch) val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress) + val bookReplacementSignature = remember(bookReplacementPreferences, bookReplacementFileId) { + bookReplacementPreferences.signatureForFile(bookReplacementFileId) + } BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) { val textMeasurer = rememberTextMeasurer() @@ -851,6 +2308,9 @@ fun PaginatedReaderScreen( var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) } var debouncedFontFamily by remember { mutableStateOf(fontFamily) } var debouncedTextAlign by remember { mutableStateOf(textAlign) } + var debouncedBookReplacementSignature by remember { mutableStateOf(bookReplacementSignature) } + var debouncedBookReplacementPreferences by remember { mutableStateOf(bookReplacementPreferences) } + var debouncedBookReplacementFileId by remember { mutableStateOf(bookReplacementFileId) } var anchorLocatorForReconfig by remember { mutableStateOf(null) } val currentPaginatorRef = remember { mutableStateOf(null) } @@ -905,19 +2365,21 @@ fun PaginatedReaderScreen( layoutTextStyle.copy(color = effectiveText) } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage }.collect { page -> - Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}") + if (DEBUG_PAGE_TURN_DIAG) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}") + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> + Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling") + } } } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> - Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling") - } - } - - LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) { + LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign, bookReplacementSignature, bookReplacementFileId) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || @@ -925,7 +2387,9 @@ fun PaginatedReaderScreen( horizontalMarginMultiplier != debouncedHorizontalMarginMult || verticalMarginMultiplier != debouncedVerticalMarginMult || fontFamily != debouncedFontFamily || - textAlign != debouncedTextAlign + textAlign != debouncedTextAlign || + bookReplacementSignature != debouncedBookReplacementSignature || + bookReplacementFileId != debouncedBookReplacementFileId ) { Timber.d("Formatting changed. Waiting for debounce.") delay(400L) @@ -948,6 +2412,9 @@ fun PaginatedReaderScreen( debouncedVerticalMarginMult = verticalMarginMultiplier debouncedFontFamily = fontFamily debouncedTextAlign = textAlign + debouncedBookReplacementSignature = bookReplacementSignature + debouncedBookReplacementPreferences = bookReplacementPreferences + debouncedBookReplacementFileId = bookReplacementFileId Timber.d("Debounce complete. Applying new format settings.") } } @@ -1021,7 +2488,7 @@ fun PaginatedReaderScreen( } } - val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) { + val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult, debouncedBookReplacementSignature, debouncedBookReplacementFileId) { val userAgentStylesheet = UserAgentStylesheet.default var allRules = OptimizedCssRules() val allFontFaces = mutableListOf() @@ -1087,7 +2554,9 @@ fun PaginatedReaderScreen( userTextAlign = userTextAlign, paragraphGapMultiplier = debouncedParagraphGapMult, imageSizeMultiplier = debouncedImageSizeMult, - verticalMarginMultiplier = debouncedVerticalMarginMult + verticalMarginMultiplier = debouncedVerticalMarginMult, + bookReplacementPreferences = debouncedBookReplacementPreferences, + bookReplacementFileId = debouncedBookReplacementFileId ) } @@ -1096,6 +2565,15 @@ fun PaginatedReaderScreen( currentPaginatorRef.value = paginator } + DisposableEffect(paginator) { + onDispose { + if (currentPaginatorRef.value === paginator) { + currentPaginatorRef.value = null + } + paginator.dispose() + } + } + LaunchedEffect(paginator) { if (anchorLocatorForReconfig != null) { Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig") @@ -1278,7 +2756,7 @@ fun PaginatedReaderScreen( val startTime = System.currentTimeMillis() val result = paginator.getPageContent(pageIndex) val duration = System.currentTimeMillis() - startTime - if (duration > 16) { + if (DEBUG_PAGE_TURN_DIAG && duration > 16) { Timber.tag("PageTurnDiag") .w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}") } @@ -1300,6 +2778,10 @@ fun PaginatedReaderScreen( onInternalLinkNavigated = onInternalLinkNavigated, onLinkClick = { currentChapterPath, href, onNavComplete -> coroutineScope.launch(Dispatchers.IO) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_request currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) withContext(Dispatchers.Main) { isNavigatingByLink = true } try { var isFootnote = false @@ -1307,6 +2789,12 @@ fun PaginatedReaderScreen( val sourceChapter = book.chaptersForPagination.find { it.absPath == currentChapterPath } + if (sourceChapter == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "nav_source_chapter_miss currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) + } if (sourceChapter != null) { val sourceHtml = sourceChapter.htmlContent.ifEmpty { try { @@ -1394,8 +2882,15 @@ fun PaginatedReaderScreen( } if (!footnoteHtml.isNullOrBlank()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_footnote_open href=${href.readerLinkDiagPreview()} htmlChars=${footnoteHtml?.length ?: 0}" + ) withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) } } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_resolve_start currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href) withContext(Dispatchers.Main) { if (targetPage != null) { @@ -1406,12 +2901,20 @@ fun PaginatedReaderScreen( Timber.tag(TAG_STABLE_PAGE_NAV).d( "link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch" ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "nav_resolve_success href=${href.readerLinkDiagPreview()} targetPage=$targetPage " + + "targetAnchor=$targetAnchor" + ) paginator.onUserScrolledTo(targetPage) onNavComplete(targetPage) } else { Timber.tag(TAG_STABLE_PAGE_NAV).w( "link_failed href=$href currentChapterPath=$currentChapterPath" ) + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "nav_resolve_failed currentChapterPath=${currentChapterPath.readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) } } } @@ -1467,6 +2970,1413 @@ fun PaginatedReaderScreen( } } +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@OptIn(ExperimentalSerializationApi::class, FlowPreview::class) +@Composable +fun NativeVerticalReaderScreen( + modifier: Modifier = Modifier, + book: EpubBook, + bookId: String? = null, + isDarkTheme: Boolean, + effectiveBg: Color, + effectiveText: Color, + searchQuery: String, + fontSizeMultiplier: Float, + lineHeightMultiplier: Float, + paragraphGapMultiplier: Float, + imageSizeMultiplier: Float, + horizontalMarginMultiplier: Float, + verticalMarginMultiplier: Float, + fontFamily: FontFamily, + textAlign: ReaderTextAlign, + bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(), + bookReplacementFileId: String? = bookId, + ttsHighlightInfo: TtsHighlightInfo?, + initialLocator: Locator? = null, + initialPageIndexInBook: Int = 0, + scrollRequestPage: Int? = null, + scrollRequestLocator: Locator? = null, + scrollRequestLocatorId: Long = 0L, + scrollRequestLocatorKeepVisible: Boolean = false, + scrollRequestProgressPercent: Float? = null, + scrollRequestProgressId: Long = 0L, + scrollDeltaRequest: Float? = null, + scrollDeltaRequestId: Long = 0L, + scrollDeltaRequestAnimated: Boolean = true, + onScrollRequestConsumed: () -> Unit = {}, + onScrollLocatorRequestConsumed: () -> Unit = {}, + onScrollProgressRequestConsumed: () -> Unit = {}, + onScrollDeltaConsumed: () -> Unit = {}, + onPaginatorReady: (IPaginator) -> Unit, + onVisiblePageChanged: (pageIndex: Int, chapterIndex: Int?, locator: Locator?) -> Unit = { _, _, _ -> }, + onProgressChanged: (pageIndex: Int, totalPages: Int, progressPercent: Float) -> Unit = { _, _, _ -> }, + onLocationChanged: (NativeVerticalLocation) -> Unit = {}, + onTap: (Offset?) -> Unit, + isProUser: Boolean, + isOss: Boolean = false, + onShowDictionaryUpsellDialog: () -> Unit, + onWordSelectedForAiDefinition: (String) -> Unit, + onTranslate: (String) -> Unit, + onSearch: (String) -> Unit, + onStartTtsFromSelection: (String, Int, Int?) -> Unit, + onNoteRequested: (String?) -> Unit, + onFootnoteRequested: (String) -> Unit = {}, + onInternalLinkNavigated: (Int, Locator?) -> Unit = { _, _ -> }, + userHighlights: List, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, + onHighlightDeleted: (String) -> Unit, + activeHighlightPalette: List, + onUpdatePalette: (Int, HighlightColor) -> Unit, + activeTextureId: String? = null, + activeTextureAlpha: Float = 0.55f +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val textureBitmap = remember(activeTextureId) { + loadReaderTextureBitmap(context, activeTextureId) + } + val textureModifier = if (textureBitmap != null) { + Modifier.drawBehind { + val brush = ShaderBrush( + ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated) + ) + drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f)) + } + } else { + Modifier + } + val bookReplacementSignature = remember(bookReplacementPreferences, bookReplacementFileId) { + bookReplacementPreferences.signatureForFile(bookReplacementFileId) + } + var rootWindowBounds by remember { mutableStateOf(Rect.Zero) } + var rootCoords by remember { mutableStateOf(null) } + val hapticFeedback = LocalHapticFeedback.current + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(effectiveBg) + .then(textureModifier) + .onGloballyPositioned { coords -> + rootWindowBounds = Rect(coords.positionInWindow(), coords.size.toSize()) + } + .testTag("NativeVerticalReader") + ) { + val textMeasurer = rememberTextMeasurer() + val baseTextStyle = MaterialTheme.typography.bodyLarge + val density = LocalDensity.current + val layoutTextStyle = remember( + baseTextStyle, + fontSizeMultiplier, + lineHeightMultiplier, + fontFamily + ) { + val adjustedFontSize = baseTextStyle.fontSize * fontSizeMultiplier + val adjustedLineHeight = + adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(lineHeightMultiplier) + + baseTextStyle.copy( + color = Color.Unspecified, + fontSize = adjustedFontSize, + lineHeight = adjustedLineHeight, + fontFamily = fontFamily, + lineBreak = LineBreak.Paragraph, + letterSpacing = TextUnit.Unspecified, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Proportional, + trim = LineHeightStyle.Trim.None + ) + ) + } + val textStyle = remember(layoutTextStyle, effectiveText) { + layoutTextStyle.copy(color = effectiveText) + } + val userTextAlign = remember(textAlign) { + when (textAlign) { + ReaderTextAlign.JUSTIFY -> TextAlign.Justify + ReaderTextAlign.LEFT -> TextAlign.Left + ReaderTextAlign.RIGHT -> TextAlign.Right + ReaderTextAlign.DEFAULT -> null + } + } + val requestedHorizontalPadding = 16.dp * horizontalMarginMultiplier + val requestedVerticalPadding = 16.dp * verticalMarginMultiplier + val effectiveReaderPadding = + remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) { + val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() } + val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() } + val minReadableWidthPx = with(density) { 96.dp.roundToPx() } + .coerceAtMost(this.constraints.maxWidth) + val minReadableHeightPx = with(density) { 160.dp.roundToPx() } + .coerceAtMost(this.constraints.maxHeight) + val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost( + ((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0) + ) + val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost( + ((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0) + ) + with(density) { + horizontalPaddingPx.toDp() to verticalPaddingPx.toDp() + } + } + val horizontalPadding = effectiveReaderPadding.first + val verticalPadding = effectiveReaderPadding.second + val textConstraints = + remember(this.constraints, density, horizontalPadding, verticalPadding) { + val horizontalPaddingPx = with(density) { horizontalPadding.roundToPx() } + val verticalPaddingPx = with(density) { verticalPadding.roundToPx() } + this.constraints.copy( + minWidth = 0, + maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1), + minHeight = 0, + maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1) + ) + } + + val mathMLRenderer = remember { MathMLRenderer(context.applicationContext) } + DisposableEffect(Unit) { + onDispose { + mathMLRenderer.destroy() + Timber.d("NativeVerticalReaderScreen disposed, MathMLRenderer destroyed.") + } + } + + val paginator = remember( + book, + bookId, + textConstraints, + layoutTextStyle, + userTextAlign, + paragraphGapMultiplier, + imageSizeMultiplier, + verticalMarginMultiplier, + bookReplacementSignature, + bookReplacementFileId + ) { + val userAgentStylesheet = UserAgentStylesheet.default + var allRules = OptimizedCssRules() + val allFontFaces = mutableListOf() + + val uaResult = CssParser.parse( + cssContent = userAgentStylesheet, + cssPath = null, + baseFontSizeSp = layoutTextStyle.fontSize.value, + density = density.density, + constraints = textConstraints, + isDarkTheme = false, + adaptThemeColors = false + ) + allRules = allRules.merge(uaResult.rules) + allFontFaces.addAll(uaResult.fontFaces) + + book.css.forEach { (path, content) -> + val bookCssResult = CssParser.parse( + cssContent = content, + cssPath = path, + baseFontSizeSp = layoutTextStyle.fontSize.value, + density = density.density, + constraints = textConstraints, + isDarkTheme = false, + adaptThemeColors = false + ) + allRules = allRules.merge(bookCssResult.rules) + allFontFaces.addAll(bookCssResult.fontFaces) + } + + val fontFamilyMap = loadFontFamilies( + fontFaces = allFontFaces, + extractionPath = book.extractionBasePath + ) + val bookCacheDao = + BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() + val proto = ProtoBuf { serializersModule = semanticBlockModule } + val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title + val initialChapter = initialLocator?.chapterIndex ?: 0 + + Timber.tag("NativeVerticalReader").d( + "Instantiating BookPaginator for native vertical. initialChapter=$initialChapter" + ) + BookPaginator( + coroutineScope = coroutineScope, + chapters = book.chaptersForPagination, + textMeasurer = textMeasurer, + constraints = textConstraints, + textStyle = layoutTextStyle, + extractionBasePath = book.extractionBasePath, + density = density, + fontFamilyMap = fontFamilyMap, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + bookId = uniqueBookId, + bookCacheDao = bookCacheDao, + proto = proto, + initialChapterToPaginate = initialChapter, + bookCss = book.css, + userAgentStylesheet = userAgentStylesheet, + allFontFaces = allFontFaces, + context = context.applicationContext, + mathMLRenderer = mathMLRenderer, + userTextAlign = userTextAlign, + paragraphGapMultiplier = paragraphGapMultiplier, + imageSizeMultiplier = imageSizeMultiplier, + verticalMarginMultiplier = verticalMarginMultiplier, + bookReplacementPreferences = bookReplacementPreferences, + bookReplacementFileId = bookReplacementFileId + ) + } + + LaunchedEffect(paginator) { + onPaginatorReady(paginator) + } + + DisposableEffect(paginator) { + onDispose { + paginator.dispose() + } + } + + var isLoading by remember { mutableStateOf(true) } + var totalPageCount by remember { mutableIntStateOf(0) } + var generation by remember { mutableIntStateOf(0) } + + LaunchedEffect(paginator) { + launch { + snapshotFlow { paginator.isLoading }.collect { isLoading = it } + } + launch { + snapshotFlow { paginator.totalPageCount }.collect { totalPageCount = it } + } + launch { + snapshotFlow { paginator.generation }.collect { generation = it } + } + } + + val listState = rememberLazyListState() + val blockLayoutMap = remember(paginator) { ReactiveBlockMap() } + val chapterLayoutMap = remember(paginator) { mutableStateMapOf() } + val flowItemLayoutMap = remember(paginator) { mutableStateMapOf() } + var flowChapters by remember(paginator) { mutableStateOf?>(null) } + val flowItems = remember(flowChapters) { buildNativeVerticalFlowItems(flowChapters.orEmpty()) } + var isFlowLoading by remember(paginator) { mutableStateOf(true) } + val initialNativeLocator = remember(paginator) { initialLocator } + val initialNativePageIndex = remember(paginator) { initialPageIndexInBook } + var didInitialScroll by remember(paginator) { mutableStateOf(false) } + val placeholderFlowChapters = remember(book) { + book.chaptersForPagination.mapIndexed { chapterIndex, chapter -> + NativeVerticalFlowChapter( + chapterIndex = chapterIndex, + title = chapter.title, + blocks = emptyList(), + isLoaded = false, + estimatedLocationWeight = chapter.plainTextCharacterCount().coerceAtLeast(24) + ) + } + } + val flowChapterLoadsInFlight = remember(paginator) { mutableStateMapOf() } + + fun ensurePlaceholderFlowChapters() { + val current = flowChapters + if (current == null || current.size != placeholderFlowChapters.size) { + flowChapters = placeholderFlowChapters + } + } + + suspend fun loadFlowChapter(chapterIndex: Int): Boolean { + if (chapterIndex !in placeholderFlowChapters.indices) return false + flowChapters?.getOrNull(chapterIndex)?.takeIf { it.isLoaded }?.let { return true } + while (flowChapterLoadsInFlight[chapterIndex] == true) { + delay(16L) + flowChapters?.getOrNull(chapterIndex)?.takeIf { it.isLoaded }?.let { return true } + } + + flowChapterLoadsInFlight[chapterIndex] = true + return try { + val chapter = book.chaptersForPagination.getOrNull(chapterIndex) ?: return false + val blocks = try { + paginator.getFlowBlocksForChapter(chapterIndex).orEmpty() + } catch (e: Exception) { + Timber.e(e, "Native vertical flow failed to load chapter $chapterIndex") + emptyList() + } + val current = flowChapters ?: placeholderFlowChapters + val updated = current.toMutableList() + updated[chapterIndex] = NativeVerticalFlowChapter( + chapterIndex = chapterIndex, + title = chapter.title, + blocks = blocks, + isLoaded = true, + estimatedLocationWeight = chapter.plainTextCharacterCount().coerceAtLeast(24) + ) + flowChapters = updated + true + } finally { + flowChapterLoadsInFlight.remove(chapterIndex) + } + } + + @Suppress("UNUSED_PARAMETER") + suspend fun scrollToFlowLocator( + locator: Locator?, + animate: Boolean, + keepVisible: Boolean = false + ): Boolean { + if (locator == null) return false + ensurePlaceholderFlowChapters() + if (flowChapters?.getOrNull(locator.chapterIndex)?.isLoaded != true) { + loadFlowChapter(locator.chapterIndex) + withFrameNanos { } + } + val chapters = flowChapters ?: return false + val currentFlowItems = buildNativeVerticalFlowItems(chapters) + val exactDelta = resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds = rootWindowBounds, + chapterLayoutMap = chapterLayoutMap, + flowItems = currentFlowItems, + flowItemLayoutMap = flowItemLayoutMap, + blockLayoutMap = blockLayoutMap, + chapters = chapters, + locator = locator, + allowChapterFallback = false + ) + if (exactDelta != null) { + val scrollDelta = if (keepVisible) { + val viewportHeight = rootWindowBounds.height + val comfortableTop = viewportHeight * 0.24f + val comfortableBottom = viewportHeight * 0.76f + if (exactDelta in comfortableTop..comfortableBottom) { + 0f + } else { + exactDelta - (viewportHeight * 0.38f) + } + } else { + exactDelta + } + if (abs(scrollDelta) > 1f) { + listState.scrollBy(scrollDelta) + } + if (keepVisible || abs(exactDelta) > 1f) return true + } + + val targetIndex = findNativeVerticalFlowItemIndexForLocator( + items = currentFlowItems, + chapters = chapters, + locator = locator + ) ?: return false + listState.scrollToItem(targetIndex) + repeat(4) { + withFrameNanos { } + val refinedDelta = resolveNativeVerticalScrollDeltaForLocator( + rootWindowBounds = rootWindowBounds, + chapterLayoutMap = chapterLayoutMap, + flowItems = currentFlowItems, + flowItemLayoutMap = flowItemLayoutMap, + blockLayoutMap = blockLayoutMap, + chapters = chapters, + locator = locator, + allowChapterFallback = false + ) + if (refinedDelta != null) { + val scrollDelta = if (keepVisible) { + val viewportHeight = rootWindowBounds.height + refinedDelta - (viewportHeight * 0.38f) + } else { + refinedDelta + } + if (abs(scrollDelta) > 1f) { + listState.scrollBy(scrollDelta) + } + return true + } + } + return true + } + + suspend fun scrollToCompatPage(pageIndex: Int, animate: Boolean): Boolean { + val targetPage = pageIndex.coerceIn(0, (totalPageCount - 1).coerceAtLeast(0)) + val locator = paginator.getLocatorForPage(targetPage) + ?: paginator.findChapterIndexForPage(targetPage)?.let { Locator(it, 0, 0) } + ?: return false + val didScroll = scrollToFlowLocator(locator, animate) + if (didScroll) paginator.onUserScrolledTo(targetPage) + return didScroll + } + + suspend fun scrollToProgressPercent(progressPercent: Float): Boolean { + if (flowItems.isEmpty()) return false + val targetIndex = findNativeVerticalFlowItemIndexForProgress( + items = flowItems, + progressPercent = progressPercent + ) ?: return false + listState.scrollToItem(targetIndex) + paginator.onUserScrolledTo( + nativeVerticalCompatPageForProgress(progressPercent, totalPageCount) + ) + return true + } + + LaunchedEffect(paginator) { + snapshotFlow { paginator.isLoading }.filter { !it }.first() + isFlowLoading = true + if (placeholderFlowChapters.isEmpty()) { + flowChapters = emptyList() + isFlowLoading = false + return@LaunchedEffect + } + + flowChapters = placeholderFlowChapters + val initialChapter = ( + initialNativeLocator?.chapterIndex + ?: paginator.findChapterIndexForPage(initialNativePageIndex) + ?: 0 + ).coerceIn(0, placeholderFlowChapters.lastIndex) + val prefetchOrder = nativeVerticalInitialChapterPrefetchOrder( + chapterCount = placeholderFlowChapters.size, + initialChapter = initialChapter + ) + + loadFlowChapter(initialChapter) + isFlowLoading = false + + prefetchOrder.forEach { chapterIndex -> + if (!isActive) return@LaunchedEffect + loadFlowChapter(chapterIndex) + delay(16L) + } + } + + LaunchedEffect(flowChapters, totalPageCount, rootWindowBounds) { + if (didInitialScroll || flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + val targetLocator = initialNativeLocator ?: paginator.getLocatorForPage(initialNativePageIndex) + if (targetLocator == null) { + didInitialScroll = true + return@LaunchedEffect + } + val didScroll = scrollToFlowLocator(targetLocator, animate = false) || + scrollToCompatPage(initialNativePageIndex, animate = false) + if (didScroll) { + didInitialScroll = true + } + } + + LaunchedEffect(scrollRequestPage, totalPageCount, flowChapters, rootWindowBounds) { + val requestedPage = scrollRequestPage ?: return@LaunchedEffect + if (totalPageCount <= 0 || flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + if (scrollToCompatPage(requestedPage, animate = true)) { + onScrollRequestConsumed() + } + } + + LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) { + val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect + if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect + if (scrollToFlowLocator(requestedLocator, animate = false, keepVisible = scrollRequestLocatorKeepVisible)) { + paginator.onUserScrolledTo( + nativeVerticalCompatPageForProgress( + estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f, + totalPageCount + ) + ) + onScrollLocatorRequestConsumed() + } + } + + LaunchedEffect(scrollRequestProgressId, scrollRequestProgressPercent, flowChapters) { + val requestedProgress = scrollRequestProgressPercent ?: return@LaunchedEffect + if (flowChapters == null) return@LaunchedEffect + if (scrollToProgressPercent(requestedProgress)) { + onScrollProgressRequestConsumed() + } + } + + LaunchedEffect(scrollDeltaRequestId, scrollDeltaRequest, scrollDeltaRequestAnimated) { + val delta = scrollDeltaRequest ?: return@LaunchedEffect + if (delta != 0f) { + if (scrollDeltaRequestAnimated) { + listState.animateScrollBy(delta) + } else { + listState.scrollBy(delta) + } + } + onScrollDeltaConsumed() + } + + var lastReportedVisiblePage by remember { mutableIntStateOf(-1) } + var lastReportedTotalPageCount by remember { mutableIntStateOf(0) } + var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) } + var lastReportedLocator by remember { mutableStateOf(null) } + var lastReportedVisibleTextRanges by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val firstVisibleItemSize = visibleItems + .firstOrNull { it.index == listState.firstVisibleItemIndex } + ?.size + ?: 0 + val lastVisibleItem = visibleItems.lastOrNull() + val isAtEnd = layoutInfo.totalItemsCount > 0 && + lastVisibleItem?.index == layoutInfo.totalItemsCount - 1 && + lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset + NativeVerticalViewportSample( + firstVisiblePageIndex = listState.firstVisibleItemIndex, + firstVisiblePageScrollOffset = listState.firstVisibleItemScrollOffset, + firstVisibleItemSize = firstVisibleItemSize, + isAtStart = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0, + isAtEnd = isAtEnd, + totalPageCount = totalPageCount.takeIf { it > 0 } ?: (flowChapters?.size ?: 0), + layoutTick = blockLayoutMap.tick, + initialScrollComplete = didInitialScroll + ) + } + .debounce(80) + .collectLatest { sample -> + if (!sample.initialScrollComplete) return@collectLatest + val total = sample.totalPageCount + if (total <= 0) return@collectLatest + blockLayoutMap.pruneDetached() + val locator = resolveNativeVerticalFlowVisibleLocator( + rootWindowBounds = rootWindowBounds, + blockLayoutMap = blockLayoutMap + ) ?: flowItems.getOrNull(sample.firstVisiblePageIndex) + ?.let { locatorForNativeVerticalFlowItem(it) } + val visibleTextRanges = resolveNativeVerticalVisibleTextRanges( + rootWindowBounds = rootWindowBounds, + blockLayoutMap = blockLayoutMap + ) + val progressPercent = when { + sample.isAtEnd -> 100f + sample.isAtStart -> 0f + else -> estimateNativeVerticalScrollProgressPercent( + items = flowItems, + firstVisibleItemIndex = sample.firstVisiblePageIndex, + firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset, + firstVisibleItemSize = sample.firstVisibleItemSize + ) ?: estimateNativeVerticalProgressPercent( + book = book, + locator = locator + ) ?: 0f + } + val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total) + paginator.onUserScrolledTo(compatPage) + + if ( + compatPage != lastReportedVisiblePage || + total != lastReportedTotalPageCount || + abs(progressPercent - lastReportedProgressPercent) >= 0.05f || + locator != lastReportedLocator || + visibleTextRanges != lastReportedVisibleTextRanges + ) { + lastReportedVisiblePage = compatPage + lastReportedTotalPageCount = total + lastReportedProgressPercent = progressPercent + lastReportedLocator = locator + lastReportedVisibleTextRanges = visibleTextRanges + onLocationChanged( + NativeVerticalLocation( + locator = locator, + chapterIndex = locator?.chapterIndex, + progressPercent = progressPercent, + compatPageIndex = compatPage, + compatTotalPages = total, + firstVisibleItemIndex = sample.firstVisiblePageIndex, + firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset, + firstVisibleItemSize = sample.firstVisibleItemSize, + isAtStart = sample.isAtStart, + isAtEnd = sample.isAtEnd, + visibleTextRanges = visibleTextRanges + ) + ) + onProgressChanged(compatPage, total, progressPercent) + onVisiblePageChanged(compatPage, locator?.chapterIndex, locator) + } + } + } + + val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) + var activeSelection by remember { mutableStateOf(null) } + var isDraggingHandle by remember { mutableStateOf(false) } + var selectionEdgeScrollDelta by remember { mutableFloatStateOf(0f) } + var selectionEdgeDragWindowPos by remember { mutableStateOf(Offset.Unspecified) } + var selectionEdgeDragHandle by remember { mutableStateOf(null) } + val activeDragHandleForDisplay = selectionEdgeDragHandle + var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) } + val magnifierModifier = if (magnifierCenter.isSpecified) { + Modifier.magnifier( + sourceCenter = { magnifierCenter }, + zoom = 1.5f, + size = DpSize(140.dp, 48.dp), + cornerRadius = 24.dp, + elevation = 4.dp + ) + } else { + Modifier + } + var showPaletteManager by remember { mutableStateOf(false) } + var showExternalLinkDialog by remember { mutableStateOf(null) } + val imageLoader = context.imageLoader + + showExternalLinkDialog?.let { urlToShow -> + AlertDialog( + onDismissRequest = { showExternalLinkDialog = null }, + title = { Text(stringResource(R.string.dialog_external_link_title)) }, + text = { Text(urlToShow) }, + confirmButton = { + TextButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri()) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "No activity found to handle intent for URL: $urlToShow") + Toast.makeText( + context, + context.getString(R.string.error_no_browser), + Toast.LENGTH_LONG + ).show() + } + showExternalLinkDialog = null + } + ) { Text(stringResource(R.string.action_open)) } + }, + dismissButton = { + TextButton(onClick = { + val clipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboardManager.setPrimaryClip( + ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow) + ) + showExternalLinkDialog = null + }) { Text(stringResource(R.string.action_copy)) } + } + ) + } + + val renderedFlowChapters = flowChapters + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { rootCoords = it } + .then(magnifierModifier) + ) { + if (isFlowLoading || renderedFlowChapters == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (renderedFlowChapters.isNotEmpty()) { + generation + val chapterBoundaryGap = 44.dp * verticalMarginMultiplier.coerceIn(0.75f, 2.5f) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding) + ) { + itemsIndexed( + items = flowItems, + key = { _, item -> item.key } + ) { _, item -> + val chapterIndex = item.chapterIndex + val block = item.block + val onGeneralTapCallback: (Offset) -> Unit = { offset -> + activeSelection = null + onTap(offset) + } + val onLinkClickCallback: (String) -> Unit = { href -> + if (href.isReaderExternalHref()) { + showExternalLinkDialog = href.readerExternalHrefForDisplay() + } else { + val chapterPath = book.chaptersForPagination.getOrNull(chapterIndex)?.absPath + coroutineScope.launch { + val footnoteHtml = withContext(Dispatchers.IO) { + resolveReaderFootnoteHtml(book, chapterPath.orEmpty(), href) + } + if (!footnoteHtml.isNullOrBlank()) { + onFootnoteRequested(footnoteHtml) + return@launch + } + val targetLocator = paginator.findStableLocatorForHref(chapterPath.orEmpty(), href) + val targetPage = targetLocator?.let { paginator.findStablePageForLocator(it) } + ?: paginator.findStablePageForHref(chapterPath.orEmpty(), href) + if (targetPage != null) { + if (targetLocator != null) { + scrollToFlowLocator(targetLocator, animate = false) + paginator.onUserScrolledTo(targetPage) + } else { + scrollToCompatPage(targetPage, animate = true) + } + onInternalLinkNavigated(targetPage, targetLocator) + } else { + Timber.tag(TAG_PAGINATED_LINK_DIAG) + .w("Native vertical link failed href=$href currentChapterPath=$chapterPath") + } + } + } + } + + if (block == null) { + if (item.kind == NativeVerticalFlowItemKind.UNLOADED_CHAPTER) { + LaunchedEffect(chapterIndex, item.kind) { + loadFlowChapter(chapterIndex) + } + } + val spacerHeight = when (item.kind) { + NativeVerticalFlowItemKind.CHAPTER_GAP -> chapterBoundaryGap + NativeVerticalFlowItemKind.UNLOADED_CHAPTER -> 72.dp + else -> 24.dp + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(spacerHeight) + .onGloballyPositioned { coords -> + flowItemLayoutMap[item.key] = coords + chapterLayoutMap[chapterIndex] = coords + } + ) + } else { + val displayBlock = remember(block, isDarkTheme, effectiveBg, effectiveText) { + Page(listOf(block)).applyReaderThemeForDisplay( + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText + ).content.first() + } + val pageUserHighlights = highlightsForPaginatedPage( + pageChapterIndex = chapterIndex, + userHighlights = userHighlights + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .background(effectiveBg) + .onGloballyPositioned { coords -> + flowItemLayoutMap[item.key] = coords + if (item.blockOrdinal <= 0) { + chapterLayoutMap[chapterIndex] = coords + } + } + .pointerInput(chapterIndex, item.blockOrdinal) { + detectTapGestures(onTap = { offset -> onTap(offset) }) + } + ) { + NativeVerticalContentBlock( + block = displayBlock, + pageIndex = chapterIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = pageUserHighlights, + activeSelection = activeSelection, + onSelectionChange = { activeSelection = it }, + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) + activeSelection = null + }, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + if (activeSelection != null) { + val sel = activeSelection!! + @Suppress("UNUSED_VARIABLE") val selectionLayoutTick = blockLayoutMap.tick + val selectedBlocks = visibleSelectedBlocks(blockLayoutMap, sel) + + if (!isDraggingHandle && selectedBlocks.isNotEmpty()) { + val handleSizePx = with(density) { 36.dp.toPx() } + val menuAnchorRect = selectionWindowBounds(sel, selectedBlocks, handleSizePx) + Popup( + popupPositionProvider = remember(menuAnchorRect, density) { + SmartPopupPositionProvider(menuAnchorRect, density) + }, + onDismissRequest = { activeSelection = null }, + properties = PopupProperties(dismissOnClickOutside = false) + ) { + PaginatedTextSelectionMenu( + onCopy = { + val clipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboardManager.setPrimaryClip( + ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text) + ) + activeSelection = null + }, + onSelectAll = null, + onDictionary = { + if (isProUser || countWords(sel.text) <= 1) { + onWordSelectedForAiDefinition(sel.text) + } else { + onShowDictionaryUpsellDialog() + } + activeSelection = null + }, + onTranslate = { + onTranslate(sel.text) + activeSelection = null + }, + onSearch = { + onSearch(sel.text) + activeSelection = null + }, + onHighlight = { color -> + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = sel.startPageIndex, + cfi = finalCfi + ) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=native_vertical_highlight_menu color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=native_vertical action=highlight color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, color.id, locator) + activeSelection = null + }, + onNote = { + onNoteRequested(null) + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = sel.startPageIndex, + cfi = finalCfi + ) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=native_vertical_note_menu color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=native_vertical action=note color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id, locator) + activeSelection = null + }, + onTts = { + val startAbs = sel.startOffset + sel.startBlockCharOffset + onStartTtsFromSelection(sel.startBaseCfi, startAbs, sel.startPageIndex) + activeSelection = null + }, + onDelete = null, + isProUser = isProUser, + isOss = isOss, + activeHighlightPalette = activeHighlightPalette, + onOpenPaletteManager = { showPaletteManager = true } + ) + } + } + + val latestActiveSelection by rememberUpdatedState(activeSelection) + val updateSelection: (Offset, SelectionHandle, Boolean) -> SelectionHandle = + updateSelection@ { windowPos, currentDragHandle, withHaptic -> + val currentSelection = latestActiveSelection ?: return@updateSelection currentDragHandle + val attachedBlocks = attachedSelectionBlocks(blockLayoutMap) + val updated = updatedSelectionForHandleDrag( + selection = currentSelection, + windowPos = windowPos, + currentDragHandle = currentDragHandle, + attachedBlocks = attachedBlocks, + blockLayoutMap = blockLayoutMap + ) + if (updated != null) { + if (withHaptic) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + activeSelection = updated.first + updated.second + } else { + currentDragHandle + } + } + + val latestUpdateSelection by rememberUpdatedState(updateSelection) + + LaunchedEffect(isDraggingHandle) { + while (isDraggingHandle && isActive) { + val delta = selectionEdgeScrollDelta + if (abs(delta) > 0.5f) { + listState.scrollBy(delta) + withFrameNanos { } + val handle = selectionEdgeDragHandle + val targetWindowPos = selectionEdgeDragWindowPos + if (handle != null && targetWindowPos.isSpecified) { + selectionEdgeDragHandle = latestUpdateSelection(targetWindowPos, handle, false) + } + } else { + withFrameNanos { } + } + } + selectionEdgeScrollDelta = 0f + selectionEdgeDragWindowPos = Offset.Unspecified + selectionEdgeDragHandle = null + } + + listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType -> + val isStart = handleType == SelectionHandle.START + var handleCoords by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .zIndex(8f) + .graphicsLayer { + @Suppress("UNUSED_VARIABLE") val tick = blockLayoutMap.tick + val pos = selectionHandleRootPosition( + selection = sel, + isStart = isStart, + blockLayoutMap = blockLayoutMap, + rootCoords = rootCoords + ) + val shouldShowHandle = !isDraggingHandle || + activeDragHandleForDisplay == null || + activeDragHandleForDisplay == handleType + + if (pos.isSpecified && shouldShowHandle) { + translationX = pos.x - 18.dp.toPx() + translationY = pos.y + alpha = 1f + } else { + alpha = 0f + } + } + .size(36.dp) + .onGloballyPositioned { handleCoords = it } + .pointerInput(handleType, listState) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + if (isDraggingHandle && selectionEdgeDragHandle != null) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + change.consume() + if (!change.pressed) break + } + return@awaitEachGesture + } + isDraggingHandle = true + var currentDragHandle = handleType + selectionEdgeDragHandle = currentDragHandle + selectionEdgeDragWindowPos = Offset.Unspecified + var downPointerRoot = Offset.Unspecified + var downHandleAnchorRoot = Offset.Unspecified + if ( + handleCoords != null && + rootCoords != null && + handleCoords!!.isAttached && + rootCoords!!.isAttached + ) { + try { + val pointerWindow = handleCoords!!.localToWindow(down.position) + downPointerRoot = rootCoords!!.windowToLocal(pointerWindow) + downHandleAnchorRoot = latestActiveSelection?.let { currentSelection -> + selectionHandleRootPosition( + selection = currentSelection, + isStart = isStart, + blockLayoutMap = blockLayoutMap, + rootCoords = rootCoords + ) + } ?: Offset.Unspecified + } catch (_: Exception) { + downPointerRoot = Offset.Unspecified + downHandleAnchorRoot = Offset.Unspecified + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + change.consume() + + if ( + handleCoords != null && + rootCoords != null && + handleCoords!!.isAttached && + rootCoords!!.isAttached + ) { + try { + selectionEdgeDragHandle?.let { currentDragHandle = it } + val pointerWindow = handleCoords!!.localToWindow(change.position) + val pointerRoot = rootCoords!!.windowToLocal(pointerWindow) + val edgeSize = 64.dp.toPx() + val maxScrollStep = 28.dp.toPx() + val rootHeight = rootCoords!!.size.height.toFloat() + val edgeScrollDelta = when { + pointerRoot.y < edgeSize -> + -(((edgeSize - pointerRoot.y) / edgeSize) * maxScrollStep) + .coerceIn(2.dp.toPx(), maxScrollStep) + pointerRoot.y > rootHeight - edgeSize -> + (((pointerRoot.y - (rootHeight - edgeSize)) / edgeSize) * maxScrollStep) + .coerceIn(2.dp.toPx(), maxScrollStep) + else -> 0f + } + selectionEdgeScrollDelta = edgeScrollDelta + + val targetRootPos = if ( + downPointerRoot.isSpecified && + downHandleAnchorRoot.isSpecified + ) { + downHandleAnchorRoot + (pointerRoot - downPointerRoot) + } else { + pointerRoot + } + magnifierCenter = targetRootPos + + val textHitRootPos = targetRootPos.copy( + y = targetRootPos.y - 2.dp.toPx() + ) + val targetWindowPos = rootCoords!!.localToWindow(textHitRootPos) + currentDragHandle = latestUpdateSelection(targetWindowPos, currentDragHandle, true) + selectionEdgeDragWindowPos = targetWindowPos + selectionEdgeDragHandle = currentDragHandle + } catch (_: Exception) { + // Ignore detachment during fast scroll/drag handoff. + } + } + } + isDraggingHandle = false + selectionEdgeScrollDelta = 0f + selectionEdgeDragWindowPos = Offset.Unspecified + selectionEdgeDragHandle = null + magnifierCenter = Offset.Unspecified + } + }, + contentAlignment = Alignment.TopCenter + ) { + Icon( + painter = painterResource(R.drawable.teardrop), + contentDescription = if (isStart) "Start handle" else "End handle", + modifier = Modifier + .size(36.dp) + .graphicsLayer { + rotationZ = if (isStart) 30f else -30f + transformOrigin = TransformOrigin(0.5f, 0f) + }, + tint = Color(0xFF1976D2) + ) + } + } + } + + if (showPaletteManager) { + PaletteManagerDialog( + currentPalette = activeHighlightPalette, + onDismiss = { showPaletteManager = false }, + onSave = { newPalette -> + newPalette.forEachIndexed { index, color -> + onUpdatePalette(index, color) + } + showPaletteManager = false + } + ) + } + } + } +} + +@Composable +private fun NativeVerticalPage( + page: Page, + pageIndex: Int, + textStyle: TextStyle, + imageSizeMultiplier: Float, + searchQuery: String, + searchHighlightColor: Color, + ttsHighlightInfo: TtsHighlightInfo?, + ttsHighlightColor: Color, + textMeasurer: TextMeasurer, + onLinkClickCallback: (String) -> Unit, + onGeneralTapCallback: (Offset) -> Unit, + userHighlights: List, + activeSelection: PaginatedSelection?, + onSelectionChange: (PaginatedSelection?) -> Unit, + onHighlightClick: (UserHighlight, Rect) -> Unit, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + blockLayoutMap: MutableMap>, + density: Density, + imageLoader: ImageLoader, + horizontalPadding: Dp, + effectiveBg: Color, + onTap: (Offset?) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(effectiveBg) + .padding(horizontal = horizontalPadding) + .pointerInput(pageIndex) { + detectTapGestures(onTap = { offset -> onTap(offset) }) + } + ) { + page.content.forEach { block -> + NativeVerticalContentBlock( + block = block, + pageIndex = pageIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun NativeVerticalContentBlock( + block: ContentBlock, + pageIndex: Int, + textStyle: TextStyle, + imageSizeMultiplier: Float, + searchQuery: String, + searchHighlightColor: Color, + ttsHighlightInfo: TtsHighlightInfo?, + ttsHighlightColor: Color, + textMeasurer: TextMeasurer, + onLinkClickCallback: (String) -> Unit, + onGeneralTapCallback: (Offset) -> Unit, + userHighlights: List, + activeSelection: PaginatedSelection?, + onSelectionChange: (PaginatedSelection?) -> Unit, + onHighlightClick: (UserHighlight, Rect) -> Unit, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + blockLayoutMap: MutableMap>, + density: Density, + imageLoader: ImageLoader, + modifier: Modifier = Modifier +) { + val styledModifier = modifier + .padding( + start = block.style.margin.left.coerceAtLeast(0.dp), + top = block.style.margin.top.coerceAtLeast(0.dp), + end = block.style.margin.right.coerceAtLeast(0.dp), + bottom = block.style.margin.bottom.coerceAtLeast(0.dp) + ) + .drawCssBorders(block.style, density) + .padding( + start = block.style.padding.left.coerceAtLeast(0.dp), + top = block.style.padding.top.coerceAtLeast(0.dp), + end = block.style.padding.right.coerceAtLeast(0.dp), + bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + ) + + when (block) { + is WrappingContentBlock -> { + WrappingContentLayout( + block = block, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + modifier = styledModifier, + searchQuery = searchQuery, + ttsHighlightInfo = ttsHighlightInfo, + searchHighlightColor = searchHighlightColor, + ttsHighlightColor = ttsHighlightColor, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback + ) + } + is MathBlock -> { + RenderNativeMathBlock( + block = block, + textStyle = textStyle, + imageLoader = imageLoader, + modifier = styledModifier + ) + } + is FlexContainerBlock -> { + val renderChild: @Composable (ContentBlock) -> Unit = { child -> + NativeVerticalContentBlock( + block = child, + pageIndex = pageIndex, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + modifier = Modifier.fillMaxWidth() + ) + } + if (block.style.flexDirection == "row") { + Row(modifier = styledModifier.fillMaxWidth()) { + block.children.forEach { child -> + Box(modifier = Modifier.weight(1f, fill = false)) { + renderChild(child) + } + } + } + } else { + Column(modifier = styledModifier.fillMaxWidth()) { + block.children.forEach { child -> renderChild(child) } + } + } + } + else -> { + Box(modifier = styledModifier) { + RenderFlexChildBlock( + childBlock = block, + textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = onSelectionChange, + onHighlightClick = onHighlightClick, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + pageIndex = pageIndex, + registerStableLayoutKey = true + ) + } + } + } +} + +@Composable +private fun RenderNativeMathBlock( + block: MathBlock, + textStyle: TextStyle, + imageLoader: ImageLoader, + modifier: Modifier = Modifier +) { + val svgContent = block.svgContent?.takeIf { it.isNotBlank() } + if (svgContent != null) { + val imageRequest = Builder(LocalContext.current) + .data(SvgData(svgContent)) + .listener( + onError = { _, result -> + Timber.e(result.throwable, "Coil failed to load SVG for native vertical MathBlock.") + } + ) + .build() + AsyncImage( + model = imageRequest, + contentDescription = block.altText ?: "Equation", + modifier = modifier + .fillMaxWidth() + .heightIn(min = 24.dp), + contentScale = ContentScale.Fit, + colorFilter = if (block.isFromMathJax) ColorFilter.tint(textStyle.color) else null, + imageLoader = imageLoader + ) + } else { + Text( + text = block.altText ?: "[Equation not available]", + style = textStyle, + modifier = modifier + ) + } +} + private fun parseEmphasisAnnotation(annotation: String, defaultColor: Color): TextEmphasis { Timber.d("Parsing annotation string: '$annotation'") val map = annotation.split(';').filter { it.isNotBlank() }.associate { @@ -1522,14 +4432,6 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean = internal fun getHighlightOffsetsInBlock( block: TextContentBlock, highlight: UserHighlight ): IntRange? { - if (block.cfi == null) return null - - val blockPath = CfiUtils.getPath(block.cfi!!) - 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 is HeaderBlock -> block.startCharOffsetInSource @@ -1540,12 +4442,43 @@ internal fun getHighlightOffsetsInBlock( val blockEndAbs = block.endCharOffsetInSource .takeIf { it > blockStartAbs } ?: (blockStartAbs + block.content.text.length) + val blockText = block.content.text + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_start blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs blockLen=${blockText.length} " + + "hasPreciseLocator=${highlight.locator.hasTextRange} " + + highlight.androidHighlightRenderLabel() + ) + + locatorHighlightOffsetsInBlock( + blockText = blockText, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + blockIndex = block.blockIndex, + blockCfi = block.cfi, + highlight = highlight + )?.let { return it } + + if (block.cfi == null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=missing_block_cfi blockIndex=${block.blockIndex} blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + + val blockPath = CfiUtils.getPath(block.cfi!!) + val sourceCfi = highlight.locator.cfi?.takeIf { it.isNotBlank() } ?: highlight.cfi + val parts = sourceCfi.split('|') + val startCfi = parts.firstOrNull() ?: highlight.cfi + val endCfi = parts.lastOrNull() + val isMultipartHighlight = endCfi != null && endCfi != startCfi Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_check blockCfi=${block.cfi} blockPath=$blockPath " + "blockAbs=$blockStartAbs..$blockEndAbs blockLen=${block.content.text.length} " + "highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " + - "highlightCfi=${highlight.cfi} startCfi=$startCfi endCfi=$endCfi " + + "highlightCfi=$sourceCfi startCfi=$startCfi endCfi=$endCfi " + "highlightTextLen=${highlight.text.length} highlightText='${highlightDiagSnippet(highlight.text)}'" ) @@ -1587,10 +4520,16 @@ internal fun getHighlightOffsetsInBlock( ) } - val blockText = block.content.text val highlightText = highlight.text - if (blockText.isEmpty() || highlightText.isEmpty()) return null + if (blockText.isEmpty() || highlightText.isEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=empty_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockTextLen=${blockText.length} highlightTextLen=${highlightText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } val isIntermediateBlock = relevantPart == null && isMultipartHighlight && @@ -1602,9 +4541,20 @@ internal fun getHighlightOffsetsInBlock( ) if (relevantPart == null) { - if (!isIntermediateBlock) return null + if (!isIntermediateBlock) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=no_relevant_cfi_part blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "startCfi=$startCfi endCfi=$endCfi " + + highlight.androidHighlightRenderLabel() + ) + return null + } if (highlightText.contains(blockText, ignoreCase = false)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_exact blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_exact blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1613,6 +4563,10 @@ internal fun getHighlightOffsetsInBlock( } if (highlightText.contains(blockText, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_exact_ignore_case blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_exact_ignore_case blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1623,12 +4577,21 @@ internal fun getHighlightOffsetsInBlock( val normHighlight = highlightText.filter { !it.isWhitespace() } return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=intermediate_normalized blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=intermediate_normalized blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" ) range } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=intermediate_text_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "blockText='${highlightDiagSnippet(blockText)}' " + + highlight.androidHighlightRenderLabel() + ) null } } @@ -1657,31 +4620,72 @@ internal fun getHighlightOffsetsInBlock( if (startMatches || endMatches) { val startAbs = CfiUtils.getOffsetOrNull(startCfi) val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) } + val startLocal = startAbs?.let { + cfiOffsetToBlockLocal( + offset = it, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) + } + val endLocal = endAbs?.let { + cfiOffsetToBlockLocal( + offset = it, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) + } Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_offset_inputs blockCfi=${block.cfi} highlightId=${highlight.id} " + - "blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs" + "blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs " + + "localOffsets=$startLocal..$endLocal" ) - if (startMatches && endMatches && startAbs != null && endAbs != null) { - val rangeStartAbs = minOf(startAbs, endAbs) - val rangeEndAbs = maxOf(startAbs, endAbs) - if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { + if (startMatches && endMatches && startLocal != null && endLocal != null) { + val rangeStartLocal = minOf(startLocal, endLocal) + val rangeEndLocal = maxOf(startLocal, endLocal) + if (rangeEndLocal <= 0 || rangeStartLocal >= blockText.length) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=same_path_split_outside_offsets blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "highlightLocal=$rangeStartLocal..$rangeEndLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_skip reason=same_path_split_outside_offsets blockCfi=${block.cfi} " + - "highlightId=${highlight.id} highlightAbs=$rangeStartAbs..$rangeEndAbs " + + "highlightId=${highlight.id} highlightLocal=$rangeStartLocal..$rangeEndLocal " + "blockAbs=$blockStartAbs..$blockEndAbs" ) return null } } else { - if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null - if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null + if (startMatches && startLocal != null && startLocal >= blockText.length) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=start_offset_after_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "startLocal=$startLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } + if (endMatches && endLocal != null && endLocal <= 0) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=end_offset_before_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "endLocal=$endLocal blockLen=${blockText.length} " + + highlight.androidHighlightRenderLabel() + ) + return null + } } var s = 0 var e = blockText.length if (startMatches) { - val absOffset = startAbs ?: CfiUtils.getOffset(startCfi) - val relOffset = absOffset - blockStartAbs + val rawOffset = startAbs ?: CfiUtils.getOffset(startCfi) + val relOffset = cfiOffsetToBlockLocal( + offset = rawOffset, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) if (relOffset < 0) { s = 0 @@ -1725,12 +4729,17 @@ internal fun getHighlightOffsetsInBlock( } if (endMatches) { - val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) - val relOffset = absOffset - blockStartAbs + val rawOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) + val relOffset = cfiOffsetToBlockLocal( + offset = rawOffset, + blockStartAbs = blockStartAbs, + blockEndAbs = blockEndAbs, + textLength = blockText.length + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_end_match blockCfi=${block.cfi} highlightId=${highlight.id} " + - "absOffset=$absOffset relOffset=$relOffset blockLen=${blockText.length}" + "rawOffset=$rawOffset relOffset=$relOffset blockLen=${blockText.length}" ) e = if (relOffset > blockText.length) { @@ -1745,12 +4754,23 @@ internal fun getHighlightOffsetsInBlock( if (s < e) { val range = s until e + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=cfi_offsets blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range startMatches=$startMatches endMatches=$endMatches " + + "startAbs=$startAbs endAbs=$endAbs startLocal=$startLocal endLocal=$endLocal " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=cfi_offsets blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" ) return range } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=invalid_cfi_range blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$s..$e startMatches=$startMatches endMatches=$endMatches " + + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w( "map_skip reason=invalid_range blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$s..$e" @@ -1762,6 +4782,10 @@ internal fun getHighlightOffsetsInBlock( if (highlightText.contains(blockText, ignoreCase = false)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=block_inside_highlight_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=block_inside_highlight_text blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1770,6 +4794,10 @@ internal fun getHighlightOffsetsInBlock( } if (highlightText.contains(blockText, ignoreCase = true)) { val range = 0 until blockText.length + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=block_inside_highlight_text_ignore_case blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=block_inside_highlight_text_ignore_case blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1784,6 +4812,10 @@ internal fun getHighlightOffsetsInBlock( if (startIndex >= 0) { val range = startIndex until (startIndex + highlightText.length) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=highlight_text_inside_block blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$range startIndex=$startIndex " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=highlight_text_inside_block blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" @@ -1793,6 +4825,10 @@ internal fun getHighlightOffsetsInBlock( val match = findFuzzyMatch(blockText, highlightText) if (match != null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=fuzzy_text blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "range=$match " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_result reason=fuzzy_text blockCfi=${block.cfi} " + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$match" @@ -1801,15 +4837,157 @@ internal fun getHighlightOffsetsInBlock( } if (relevantPart != null) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=cfi_match_text_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "relevantPart=$relevantPart " + highlight.androidHighlightRenderLabel() + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "map_skip reason=cfi_match_text_miss blockCfi=${block.cfi} " + "highlightId=${highlight.id} highlightCfi=${highlight.cfi}" ) } + if (highlight.locator.hasTextRange) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=precise_locator_and_cfi_miss blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + "sourceCfi=$sourceCfi " + highlight.androidHighlightRenderLabel() + ) + return null + } + + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_skip reason=no_mapping_match blockIndex=${block.blockIndex} blockCfi=${block.cfi} " + + highlight.androidHighlightRenderLabel() + ) return null } +private fun androidHighlightSourceCfi(highlight: UserHighlight): String { + return highlight.locator.cfi?.takeIf { it.isNotBlank() } ?: highlight.cfi +} + +private fun androidCfiPathsEquivalent(first: String, second: String): Boolean { + val firstPath = CfiUtils.getPath(first) + val secondPath = CfiUtils.getPath(second) + if (firstPath == secondPath || firstPath.startsWith("$secondPath/") || secondPath.startsWith("$firstPath/")) { + return true + } + val firstParts = firstPath.split('/').filter { it.isNotEmpty() } + val secondParts = secondPath.split('/').filter { it.isNotEmpty() } + if (firstParts == secondParts) return true + return firstParts.size == secondParts.size && + firstParts.isNotEmpty() && + firstParts.drop(1) == secondParts.drop(1) +} + +private fun androidHighlightHasMultipartCfiRange(highlight: UserHighlight): Boolean { + val parts = androidHighlightSourceCfi(highlight) + .split('|') + .filter { it.startsWith("/") } + if (parts.size < 2) return false + val first = parts.first() + return parts.drop(1).any { !androidCfiPathsEquivalent(first, it) } +} + +private fun androidHighlightCfiTouchesBlock(highlight: UserHighlight, blockCfi: String?): Boolean { + val blockPath = blockCfi?.takeIf { it.startsWith("/") } ?: return false + return androidHighlightSourceCfi(highlight) + .split('|') + .filter { it.startsWith("/") } + .any { androidCfiPathsEquivalent(it, blockPath) } +} + +private fun cfiOffsetToBlockLocal( + offset: Int, + blockStartAbs: Int, + blockEndAbs: Int, + textLength: Int +): Int { + return when { + offset in 0..textLength -> offset + offset in blockStartAbs..blockEndAbs -> offset - blockStartAbs + else -> offset + } +} + +private fun locatorHighlightOffsetsInBlock( + blockText: String, + blockStartAbs: Int, + blockEndAbs: Int, + blockIndex: Int, + blockCfi: String?, + highlight: UserHighlight +): IntRange? { + if (blockText.isEmpty()) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=empty_block_text blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + if (androidHighlightHasMultipartCfiRange(highlight)) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=multipart_cfi_uses_cfi_mapper blockIndex=$blockIndex blockCfi=$blockCfi " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val locatorBlockIndex = highlight.locator.blockIndex + val blockMatchesLocator = locatorBlockIndex != null && locatorBlockIndex == blockIndex + val cfiMatchesBlock = androidHighlightCfiTouchesBlock(highlight, blockCfi) + val hasStructuralScope = locatorBlockIndex != null || androidHighlightSourceCfi(highlight).startsWith("/") + if (hasStructuralScope && !blockMatchesLocator && !cfiMatchesBlock) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=structural_scope_miss blockIndex=$blockIndex blockCfi=$blockCfi " + + "blockMatchesLocator=$blockMatchesLocator cfiMatchesBlock=$cfiMatchesBlock " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val start = highlight.locator.startOffset ?: run { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=missing_start blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val end = highlight.locator.endOffset ?: run { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_skip reason=missing_end blockAbs=$blockStartAbs..$blockEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val rangeStartAbs = minOf(start, end) + val rangeEndAbs = maxOf(start, end) + if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=no_intersection blockAbs=$blockStartAbs..$blockEndAbs " + + "highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + return null + } + val localStart = (rangeStartAbs - blockStartAbs).coerceIn(0, blockText.length) + val localEnd = (rangeEndAbs - blockStartAbs).coerceIn(localStart, blockText.length) + return if (localStart < localEnd) { + val range = localStart until localEnd + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "map_result reason=locator_offsets blockAbs=$blockStartAbs..$blockEndAbs " + + "range=$range highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + range + } else { + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "locator_check_miss reason=invalid_local_range blockAbs=$blockStartAbs..$blockEndAbs " + + "local=$localStart..$localEnd highlightAbs=$rangeStartAbs..$rangeEndAbs " + + highlight.androidHighlightRenderLabel() + ) + null + } +} + private fun List.extractTextBlocks(): List { val result = mutableListOf() for (block in this) { @@ -1830,6 +5008,186 @@ private fun List.extractTextBlocks(): List { return result } +private fun LayoutCoordinates.androidEpubPageContentBounds( + horizontalPaddingPx: Int, + verticalPaddingPx: Int +): AndroidEpubPageContentBounds { + val pageTopPx = positionInWindow().y.roundToInt() + val contentTopPx = pageTopPx + verticalPaddingPx + val contentBottomPx = pageTopPx + size.height - verticalPaddingPx + return AndroidEpubPageContentBounds( + topPx = contentTopPx, + bottomPx = contentBottomPx, + widthPx = (size.width - (horizontalPaddingPx * 2)).coerceAtLeast(0), + heightPx = (contentBottomPx - contentTopPx).coerceAtLeast(0), + pageWidthPx = size.width, + pageHeightPx = size.height, + horizontalPaddingPx = horizontalPaddingPx, + verticalPaddingPx = verticalPaddingPx + ) +} + +private fun logAndroidEpubCutoff(message: String) { + if (!BuildConfig.DEBUG) return + Log.d(AndroidEpubCutoffLogTag, message) +} + +private fun Modifier.androidEpubNaturalHeight(): Modifier = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity) + ) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } +) + +private fun TextContentBlock.androidEpubSourceRangeLabel(): String { + val start = startCharOffsetInSource + val end = endCharOffsetInSource.takeIf { it > start } ?: (start + content.text.length) + return "$start..$end" +} + +private fun TextContentBlock.androidEpubKindName(): String { + return when (this) { + is HeaderBlock -> "header" + is ParagraphBlock -> "paragraph" + is QuoteBlock -> "quote" + is ListItemBlock -> "list_item" + else -> "text" + } +} + +private fun ContentBlock.androidEpubKindName(): String { + return when (this) { + is HeaderBlock -> "header" + is ParagraphBlock -> "paragraph" + is QuoteBlock -> "quote" + is ListItemBlock -> "list_item" + is TextContentBlock -> "text" + is ImageBlock -> "image" + is MathBlock -> "math" + is TableBlock -> "table" + is FlexContainerBlock -> "flex" + is WrappingContentBlock -> "wrapping" + is SpacerBlock -> "spacer" + } +} + +private fun logAndroidEpubBlockOverflowIfNeeded( + pageIndex: Int, + block: ContentBlock, + coordinates: LayoutCoordinates, + pageContentBounds: AndroidEpubPageContentBounds?, + diagnosticsContext: String, + signatureAlreadyLogged: (String) -> Boolean, + markSignatureLogged: (String) -> Unit +) { + val bounds = pageContentBounds ?: return + val blockTopPx = coordinates.positionInWindow().y.roundToInt() + val blockBottomPx = blockTopPx + coordinates.size.height + val contentOverflowPx = blockBottomPx - bounds.bottomPx + val pageClipOverflowPx = blockBottomPx - bounds.pageClipBottomPx + if (pageClipOverflowPx <= AndroidEpubCutoffTolerancePx) return + val relativeTopPx = blockTopPx - bounds.topPx + val signature = "block:$pageIndex:${block.blockIndex}:$relativeTopPx:${coordinates.size.height}:$pageClipOverflowPx" + if (signatureAlreadyLogged(signature)) return + markSignatureLogged(signature) + logAndroidEpubCutoff( + "cutoff_probe layer=android_rendered_block_overflow page=${pageIndex + 1} " + + "block=${block.blockIndex} kind=${block.androidEpubKindName()} " + + "blockTopPx=$relativeTopPx blockHeightPx=${coordinates.size.height} " + + "blockBottomPx=${blockBottomPx - bounds.topPx} contentPx=${bounds.widthPx}x${bounds.heightPx} " + + "pagePx=${bounds.pageWidthPx}x${bounds.pageHeightPx} contentOverflowPx=$contentOverflowPx " + + "pageClipOverflowPx=$pageClipOverflowPx " + + "expectedHeightPx=${block.expectedHeight} actualHeightPx=${coordinates.size.height} " + + "paddingPx=${bounds.horizontalPaddingPx}x${bounds.verticalPaddingPx} $diagnosticsContext" + ) +} + +private fun logAndroidEpubTextCutoffIfNeeded( + pageIndex: Int, + block: TextContentBlock, + layout: TextLayoutResult, + coordinates: LayoutCoordinates, + pageContentBounds: AndroidEpubPageContentBounds?, + diagnosticsContext: String, + previousSignature: String? +): String? { + val boxTopPx = coordinates.positionInWindow().y.roundToInt() + val boxHeightPx = coordinates.size.height + val lastLine = layout.lineCount - 1 + val lastLineTopPx = if (lastLine >= 0) layout.getLineTop(lastLine).roundToInt() else 0 + val lastLineBottomPx = if (lastLine >= 0) layout.getLineBottom(lastLine).roundToInt() else layout.size.height + val lastLineStart = if (lastLine >= 0) layout.getLineStart(lastLine) else 0 + val lastLineEnd = if (lastLine >= 0) layout.getLineEnd(lastLine, visibleEnd = true) else 0 + val overflowBottomInBoxPx = maxOf(layout.size.height, lastLineBottomPx) + val boxClipPx = overflowBottomInBoxPx - boxHeightPx + val bounds = pageContentBounds + val lineBottomInPagePx = if (bounds != null) { + boxTopPx + overflowBottomInBoxPx - bounds.topPx + } else { + overflowBottomInBoxPx + } + val contentOverflowPx = bounds?.let { boxTopPx + overflowBottomInBoxPx - it.bottomPx } ?: 0 + val pageClipOverflowPx = bounds?.let { boxTopPx + overflowBottomInBoxPx - it.pageClipBottomPx } ?: 0 + val contentBottomInsetPx = bounds?.let { it.bottomPx - (boxTopPx + overflowBottomInBoxPx) } + val pageClipBottomInsetPx = bounds?.let { it.pageClipBottomPx - (boxTopPx + overflowBottomInBoxPx) } + val bottomEdgeRisk = pageClipBottomInsetPx != null && pageClipBottomInsetPx in 0..AndroidEpubCutoffEdgeProbePx + if ( + boxClipPx <= AndroidEpubCutoffTolerancePx && + pageClipOverflowPx <= AndroidEpubCutoffTolerancePx && + !bottomEdgeRisk + ) { + return previousSignature + } + + val signature = buildString { + append(pageIndex) + append(':') + append(block.blockIndex) + append(':') + append(coordinates.size.width) + append('x') + append(boxHeightPx) + append(':') + append(layout.size.width) + append('x') + append(layout.size.height) + append(':') + append(lastLineBottomPx) + append(':') + append(bounds?.pageClipBottomPx ?: -1) + } + if (signature == previousSignature) return previousSignature + + val layer = if (boxClipPx > AndroidEpubCutoffTolerancePx) { + "android_text_clip" + } else if (pageClipOverflowPx > AndroidEpubCutoffTolerancePx) { + "android_text_page_overflow" + } else if (bottomEdgeRisk) { + "android_text_bottom_edge" + } else { + "android_text_page_overflow" + } + logAndroidEpubCutoff( + "cutoff_probe layer=$layer page=${pageIndex + 1} block=${block.blockIndex} " + + "kind=${block.androidEpubKindName()} boxPx=${coordinates.size.width}x$boxHeightPx " + + "layoutPx=${layout.size.width}x${layout.size.height} lines=${layout.lineCount} " + + "lastLine=$lastLine lastLineTopPx=$lastLineTopPx lastLineBottomPx=$lastLineBottomPx " + + "lastLineBottomInPagePx=$lineBottomInPagePx boxClipPx=$boxClipPx " + + "contentOverflowPx=$contentOverflowPx pageClipOverflowPx=$pageClipOverflowPx " + + "contentBottomInsetPx=${contentBottomInsetPx ?: "unknown"} " + + "pageClipBottomInsetPx=${pageClipBottomInsetPx ?: "unknown"} " + + "contentPx=${bounds?.let { "${it.widthPx}x${it.heightPx}" } ?: "unknown"} " + + "pagePx=${bounds?.let { "${it.pageWidthPx}x${it.pageHeightPx}" } ?: "unknown"} " + + "lineOffsets=$lastLineStart..$lastLineEnd sourceRange=${block.androidEpubSourceRangeLabel()} " + + "textChars=${block.content.text.length} expectedHeightPx=${block.expectedHeight} $diagnosticsContext" + ) + return signature +} + @Composable private fun TextWithEmphasis( text: AnnotatedString, @@ -1844,15 +5202,31 @@ private fun TextWithEmphasis( activeSelection: PaginatedSelection?, @Suppress("unused") onSelectionChange: (PaginatedSelection?) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit, - @Suppress("unused") isDarkTheme: Boolean, + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, + pageContentBoundsProvider: (() -> AndroidEpubPageContentBounds?)? = null, + cutoffDiagnosticsEnabled: Boolean = true, + cutoffDiagnosticsContext: String = "", onRegisterLayout: ((TextLayoutResult, LayoutCoordinates) -> Unit)? = null ) { var textLayoutResult by remember { mutableStateOf(null) } + var lastCutoffLogSignature by remember { mutableStateOf(null) } val viewConfiguration = LocalViewConfiguration.current var layoutCoordinates by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var pressedHighlightCfi by remember { mutableStateOf(null) } val density = LocalDensity.current + val latestTextLayoutResult = rememberUpdatedState(textLayoutResult) + val latestOnLinkClick = rememberUpdatedState(onLinkClick) + val latestOnGeneralTap = rememberUpdatedState(onGeneralTap) + val displayText = remember(text, isDarkTheme, themeBackgroundColor, themeTextColor, style.color) { + text.withReaderLinkDisplayStyle( + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = style.color.takeIf { it.isSpecified } ?: themeTextColor + ) + } data class EmphasisMarkInfo(val center: Offset, val radius: Float, val color: Color) data class UnderlineDrawInfo(val path: Path?, val effect: PathEffect?, val minX: Float, val maxX: Float, val y: Float, val decoStyle: String, val decoColor: Color) @@ -1862,7 +5236,7 @@ private fun TextWithEmphasis( val startTime = System.currentTimeMillis() val paths = mutableListOf>() val layout = textLayoutResult - if (layout != null && block.cfi != null && userHighlights.isNotEmpty()) { + if (layout != null && userHighlights.isNotEmpty()) { userHighlights.forEach { highlight -> val range = getHighlightOffsetsInBlock(block, highlight) if (range != null) { @@ -1878,6 +5252,12 @@ private fun TextWithEmphasis( "highlightCfi=${highlight.cfi} range=$range " + "blockText='${highlightDiagSnippet(block.content.text)}'" ) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "draw_highlight surface=native_or_paginated page=$pageIndex blockIndex=${block.blockIndex} " + + "blockCfi=${block.cfi} blockAbs=$blockStartAbs..$blockEndAbs range=$range " + + "blockText='${highlightDiagSnippet(block.content.text)}' " + + highlight.androidHighlightRenderLabel() + ) val path = layout.getPathForRange(range.first, range.last + 1) paths.add(path to highlight.color.color.copy(alpha = 0.4f)) if (highlight.cfi == pressedHighlightCfi) { @@ -2176,18 +5556,56 @@ private fun TextWithEmphasis( return null } - Text(text = text, style = style, modifier = modifier + fun logCutoffIfNeeded( + layout: TextLayoutResult?, + coordinates: LayoutCoordinates?, + pageContentBounds: AndroidEpubPageContentBounds? = pageContentBoundsProvider?.invoke() + ) { + if (!cutoffDiagnosticsEnabled) return + if (layout == null || coordinates == null || !coordinates.isAttached) return + lastCutoffLogSignature = logAndroidEpubTextCutoffIfNeeded( + pageIndex = pageIndex, + block = block, + layout = layout, + coordinates = coordinates, + pageContentBounds = pageContentBounds, + diagnosticsContext = cutoffDiagnosticsContext, + previousSignature = lastCutoffLogSignature + ) + } + + val currentPageContentBounds = pageContentBoundsProvider?.invoke() + LaunchedEffect(textLayoutResult, layoutCoordinates, currentPageContentBounds) { + logCutoffIfNeeded(textLayoutResult, layoutCoordinates, currentPageContentBounds) + } + + Text(text = displayText, style = style, modifier = modifier .onGloballyPositioned { layoutCoordinates = it + logCutoffIfNeeded(textLayoutResult, it) if (textLayoutResult != null && block.cfi != null) { onRegisterLayout?.invoke(textLayoutResult!!, it) } } .then(customDrawer) - .pointerInput(userHighlights, text) { + .pointerInput(displayText, viewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "TextWithEmphasis:block=${block.blockIndex}", + urlAtPosition = { offset -> + latestTextLayoutResult.value?.let { layout -> + displayText.readerUrlAnnotationAtPosition(layout, offset) + } + }, + touchSlop = viewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClick.value(it) } + ) + } + } + .pointerInput(userHighlights, displayText) { detectTapGestures( onLongPress = { offset -> - textLayoutResult?.let { layout -> + latestTextLayoutResult.value?.let { layout -> val charOffset = layout.getOffsetForPosition(offset) val wordBoundary = layout.getWordBoundary(charOffset) @@ -2246,7 +5664,7 @@ private fun TextWithEmphasis( } }, onTap = { offset -> - textLayoutResult?.let { layout -> + latestTextLayoutResult.value?.let { layout -> val hit = getHighlightAt(offset, layout) if (hit != null) { val (highlight, localRect) = hit @@ -2262,17 +5680,32 @@ private fun TextWithEmphasis( } val charOffset = layout.getOffsetForPosition(offset) - val urlAnnotation = text.getStringAnnotations("URL", charOffset, charOffset).firstOrNull() - if (urlAnnotation != null) onLinkClick(urlAnnotation.item) - else onGeneralTap(offset) + val url = displayText.readerUrlAnnotationAtPosition(layout, offset) + if (url != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "detect_tap_link source=TextWithEmphasis:block=${block.blockIndex} " + + "page=$pageIndex charOffset=$charOffset href=${url.readerLinkDiagPreview()}" + ) + latestOnLinkClick.value(url) + } else { + latestOnGeneralTap.value(offset) + } } } ) }, onTextLayout = { textLayoutResult = it + if (displayText.getStringAnnotations("URL", 0, displayText.length).isNotEmpty()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "layout_text source=TextWithEmphasis page=$pageIndex block=${block.blockIndex} " + + "size=${it.size.width}x${it.size.height} lines=${it.lineCount} " + + displayText.readerAnnotatedLinkDiagSummary() + ) + } if (layoutCoordinates != null && block.cfi != null) { onRegisterLayout?.invoke(it, layoutCoordinates!!) } + logCutoffIfNeeded(it, layoutCoordinates) }) } @@ -2308,7 +5741,7 @@ private fun checkLayoutMismatch( } @Suppress("unused") -@SuppressLint("UnusedBoxWithConstraintsScope") +@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber") @OptIn(ExperimentalFoundationApi::class) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Composable @@ -2329,7 +5762,7 @@ internal fun PaginatedReaderContent( onGetChapterIndex: (Int) -> Int?, onGetChapterPath: (Int) -> String?, onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit, - onInternalLinkNavigated: (Int) -> Unit, + onInternalLinkNavigated: (Int, Locator?) -> Unit, onTap: (Offset?) -> Unit, isProUser: Boolean, isOss: Boolean, @@ -2341,7 +5774,7 @@ internal fun PaginatedReaderContent( onNoteRequested: (String?) -> Unit, onGetChapterInfo: (Int) -> Pair?, userHighlights: List, - onHighlightCreated: (String, String, String) -> Unit, + onHighlightCreated: (String, String, String, SharedReaderLocator) -> Unit, onHighlightDeleted: (String) -> Unit, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, @@ -2352,6 +5785,7 @@ internal fun PaginatedReaderContent( ) { val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current + val pageViewConfiguration = LocalViewConfiguration.current var showExternalLinkDialog by remember { mutableStateOf(null) } val context = LocalContext.current val imageLoader = context.imageLoader @@ -2498,6 +5932,7 @@ internal fun PaginatedReaderContent( var pageContent by remember { mutableStateOf(null) } var currentChapterPath by remember { mutableStateOf(null) } + var pageLayoutCoordinates by remember { mutableStateOf(null) } val pageChapterIndex = onGetChapterIndex(pageIndex) val pageUserHighlights = highlightsForPaginatedPage( pageChapterIndex = pageChapterIndex, @@ -2521,19 +5956,28 @@ internal fun PaginatedReaderContent( } LaunchedEffect(pageIndex, uiState.generation) { - val fetchStartTime = System.currentTimeMillis() - Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") + if (DEBUG_PAGE_TURN_DIAG) { + Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") + } + val fetchStartTime = if (DEBUG_PAGE_TURN_DIAG) System.currentTimeMillis() else 0L pageContent = onGetPage(pageIndex) - val fetchDuration = System.currentTimeMillis() - fetchStartTime - Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms") + if (DEBUG_PAGE_TURN_DIAG) { + val fetchDuration = System.currentTimeMillis() - fetchStartTime + Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms") + } onGetChapterPath(pageIndex)?.let { currentChapterPath = it } } - SideEffect { - Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area") + LaunchedEffect(pageIndex, pageChapterIndex, currentChapterPath, themedPageContent) { + val page = themedPageContent ?: return@LaunchedEffect + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_render page=$pageIndex chapter=$pageChapterIndex " + + "chapterPath=${currentChapterPath.orEmpty().readerLinkDiagPreview()} " + + page.readerPageLinkDiagSummary() + ) } val textBlocksOnPage = @@ -2667,45 +6111,123 @@ internal fun PaginatedReaderContent( pendingCrossPageSelection = null } - Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) { + val onGeneralTapCallback: (Offset) -> Unit = { offset -> + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_general_tap source=content page=$pageIndex x=${offset.x.roundToInt()} y=${offset.y.roundToInt()}" + ) + activeSelection = null + onTap(offset) + } + val onLinkClickCallback: (String) -> Unit = { href -> + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "link_click_callback page=$pageIndex currentPagerPage=${pagerState.currentPage} " + + "chapterPath=${currentChapterPath.orEmpty().readerLinkDiagPreview()} " + + "href=${href.readerLinkDiagPreview()}" + ) + if (href.isReaderExternalHref()) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "external_link_dialog href=${href.readerLinkDiagPreview()}" + ) + showExternalLinkDialog = href.readerExternalHrefForDisplay() + } else { + val path = currentChapterPath + if (path == null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).w( + "internal_link_dropped reason=missing_current_chapter_path href=${href.readerLinkDiagPreview()}" + ) + } else { + onLinkClick(path, href) { targetPageIndex -> + onInternalLinkNavigated(targetPageIndex, null) + coroutineScope.launch { + Timber.tag(TAG_STABLE_PAGE_NAV).d( + "link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}" + ) + pagerState.scrollToPage(targetPageIndex) + } + } + } + } + } + val latestPageLayoutCoordinates = rememberUpdatedState(pageLayoutCoordinates) + val latestOnLinkClickCallback = rememberUpdatedState(onLinkClickCallback) + val pageHorizontalPaddingPx = with(density) { horizontalPadding.roundToPx() } + val pageVerticalPaddingPx = with(density) { verticalPadding.roundToPx() } + val pageContentBoundsProvider = { + pageLayoutCoordinates + ?.takeIf { it.isAttached } + ?.androidEpubPageContentBounds( + horizontalPaddingPx = pageHorizontalPaddingPx, + verticalPaddingPx = pageVerticalPaddingPx + ) + } + val cutoffLogSignatures = remember(pageIndex, uiState.generation) { + mutableStateMapOf() + } + val cutoffDiagnosticsEnabled = !uiState.isLoading + val cutoffDiagnosticsContext = + "generation=${uiState.generation} loading=${uiState.isLoading} pageCount=${uiState.totalPageCount}" + + Box( + modifier = Modifier + .fillMaxSize() + .background(effectiveBg) + .then(pageTextureModifier) + .then(pageModifier) + .onGloballyPositioned { pageLayoutCoordinates = it } + .pointerInput(pageIndex, pageViewConfiguration.touchSlop) { + awaitEachGesture { + awaitReaderLinkTap( + source = "PageLinkInterceptor:page=$pageIndex", + urlAtPosition = { offset -> + val hit = latestPageLayoutCoordinates.value + ?.takeIf { it.isAttached } + ?.let { coordinates -> + blockLayoutMap.readerLinkAtPagePosition( + pageCoordinates = coordinates, + pageIndex = pageIndex, + position = offset + ) + } + if (hit != null) { + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_link_interceptor_hit page=$pageIndex block=${hit.blockIndex} " + + "cfi=${hit.cfi.orEmpty().readerLinkDiagPreview()} " + + "href=${hit.href.readerLinkDiagPreview()}" + ) + } + hit?.href + }, + touchSlop = pageViewConfiguration.touchSlop, + onLinkClick = { latestOnLinkClickCallback.value(it) } + ) + } + } + ) { Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures( onTap = { offset -> - Timber.d("Tap detected on empty page area.") + Timber.tag(TAG_PAGINATED_LINK_DIAG).d( + "page_general_tap source=background page=$pageIndex " + + "x=${offset.x.roundToInt()} y=${offset.y.roundToInt()}" + ) activeSelection = null onTap(offset) }) - }.padding( + }) + Box(modifier = Modifier.fillMaxSize().padding( horizontal = horizontalPadding, vertical = verticalPadding ), contentAlignment = Alignment.TopStart) { if (themedPageContent != null) { val displayPage = themedPageContent - val onGeneralTapCallback: (Offset) -> Unit = { offset -> - activeSelection = null - onTap(offset) - } - val onLinkClickCallback: (String) -> Unit = { href -> - Timber.d("Link clicked: $href") - if (href.startsWith("http://") || href.startsWith("https://")) { - showExternalLinkDialog = href - } 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) - } - } - } - } - } - Column(modifier = Modifier.fillMaxSize()) { + // Measure page blocks at their natural height; pagination, not Column, owns page breaks. + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + ) { val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) val ttsHighlightColor = @@ -2738,7 +6260,12 @@ internal fun PaginatedReaderContent( Modifier.width(block.style.width) } else { Modifier.fillMaxWidth() - } + }.then( + Modifier.widthIn( + min = block.style.minWidth.takeIf { it.isSpecified && it > 0.dp } ?: Dp.Unspecified, + max = block.style.maxWidth.takeIf { it.isSpecified && it > 0.dp } ?: Dp.Unspecified + ) + ) val styleModifier = alignModifier.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) @@ -2746,11 +6273,27 @@ internal fun PaginatedReaderContent( blockStyle = block.style, density = density ) + .then(if (block.style.visibility == "hidden") Modifier.graphicsLayer(alpha = 0f) else Modifier) val diagnosticModifier = Modifier.onGloballyPositioned { coordinates -> val actualHeight = coordinates.size.height + if (cutoffDiagnosticsEnabled) { + logAndroidEpubBlockOverflowIfNeeded( + pageIndex = pageIndex, + block = block, + coordinates = coordinates, + pageContentBounds = pageContentBoundsProvider(), + diagnosticsContext = cutoffDiagnosticsContext, + signatureAlreadyLogged = { signature -> + cutoffLogSignatures[signature] == true + }, + markSignatureLogged = { signature -> + cutoffLogSignatures[signature] = true + } + ) + } if (block.expectedHeight > 0) { val snippet = when (block) { is ParagraphBlock -> block.content.text.take( @@ -2861,7 +6404,7 @@ internal fun PaginatedReaderContent( } }.then(marginModifier).then(styleModifier) - Box(modifier = diagnosticModifier) { + Box(modifier = diagnosticModifier.androidEpubNaturalHeight()) { val paddingModifier = Modifier.padding( start = block.style.padding.left.coerceAtLeast( 0.dp @@ -2881,6 +6424,19 @@ internal fun PaginatedReaderContent( if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() ) + block.style.backgroundImage + ?.trim() + ?.takeIf { it.isNotBlank() && !it.contains("gradient(", ignoreCase = true) } + ?.let { backgroundImagePath -> + val backgroundFile = remember(backgroundImagePath) { File(backgroundImagePath) } + AsyncImage( + model = if (backgroundFile.exists()) backgroundFile else backgroundImagePath, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = imageContentScale(block.style) + ) + } + @Suppress("DEPRECATION") when (block) { is ParagraphBlock -> { val paragraphStyle = textStyle.copy( @@ -2985,6 +6541,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3070,6 +6631,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3154,6 +6720,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3271,6 +6842,11 @@ internal fun PaginatedReaderContent( activeSelection = null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + pageContentBoundsProvider = pageContentBoundsProvider, + cutoffDiagnosticsEnabled = cutoffDiagnosticsEnabled, + cutoffDiagnosticsContext = cutoffDiagnosticsContext, onRegisterLayout = { layout, coords -> if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( @@ -3291,7 +6867,12 @@ internal fun PaginatedReaderContent( searchQuery = searchQuery, ttsHighlightInfo = ttsHighlightInfo, searchHighlightColor = searchHighlightColor, - ttsHighlightColor = ttsHighlightColor + ttsHighlightColor = ttsHighlightColor, + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3343,6 +6924,8 @@ internal fun PaginatedReaderContent( null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, blockLayoutMap = blockLayoutMap, density = density, imageLoader = imageLoader, @@ -3396,6 +6979,8 @@ internal fun PaginatedReaderContent( null }, isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, blockLayoutMap = blockLayoutMap, density = density, imageLoader = imageLoader, @@ -3619,16 +7204,13 @@ internal fun PaginatedReaderContent( Modifier } ) - .onGloballyPositioned { coords -> - Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px") - } AsyncImage( model = imageRequest, contentDescription = block.altText ?: "Image from EPUB", modifier = finalImageModifier, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(style), colorFilter = colorFilter ) } @@ -3731,20 +7313,30 @@ internal fun PaginatedReaderContent( cell.content.forEach { blockInCell -> when (blockInCell) { is ParagraphBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } is HeaderBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle.copy( fontWeight = FontWeight.Bold ), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3762,12 +7354,17 @@ internal fun PaginatedReaderContent( ) ) } - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, modifier = Modifier.weight( 1f - ) + ), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } } @@ -3796,7 +7393,7 @@ internal fun PaginatedReaderContent( ) .build(), contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(blockInCell.style), modifier = tableCellImageModifier( block = blockInCell, density = density, @@ -3806,10 +7403,15 @@ internal fun PaginatedReaderContent( } is TextContentBlock -> { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } @@ -3963,6 +7565,10 @@ internal fun PaginatedReaderContent( "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" val absoluteCandidateCfi = "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = onGetChapterIndex(sel.startPageIndex), + cfi = finalCfi + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "create_request source=highlight_menu color=${color.id} " + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + @@ -3974,7 +7580,18 @@ internal fun PaginatedReaderContent( "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" ) - onHighlightCreated(finalCfi, sel.text, color.id) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=paginated action=highlight color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, color.id, locator) activeSelection = null }, onNote = { @@ -3985,6 +7602,10 @@ internal fun PaginatedReaderContent( "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" val absoluteCandidateCfi = "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + val locator = sel.toSharedHighlightLocator( + chapterIndex = onGetChapterIndex(sel.startPageIndex), + cfi = finalCfi + ) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "create_request source=note_menu color=${HighlightColor.YELLOW.id} " + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + @@ -3996,7 +7617,18 @@ internal fun PaginatedReaderContent( "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" ) - onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id) + Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG).d( + "create_request surface=paginated action=note color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "locator=${locator} textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id, locator) activeSelection = null }, onTts = { @@ -4438,10 +8070,13 @@ private fun RenderFlexChildBlock( onSelectionChange: (PaginatedSelection?) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit, isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color, blockLayoutMap: MutableMap>, density: Density, imageLoader: ImageLoader, - pageIndex: Int + pageIndex: Int, + registerStableLayoutKey: Boolean = false ) { @Composable fun renderTextBlock(block: TextContentBlock) { @@ -4497,9 +8132,16 @@ private fun RenderFlexChildBlock( onSelectionChange = onSelectionChange, onHighlightClick = onHighlightClick, isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, onRegisterLayout = { layout, coords -> block.cfi?.let { cfi -> - blockLayoutMap["${cfi}_$pageIndex"] = Triple(layout, coords, block) + val key = if (registerStableLayoutKey) { + textBlockLayoutKey(cfi, pageIndex, block) + } else { + legacyTextBlockLayoutKey(cfi, pageIndex) + } + blockLayoutMap[key] = Triple(layout, coords, block) } }) } @@ -4612,7 +8254,7 @@ private fun RenderFlexChildBlock( .build(), contentDescription = childBlock.altText, modifier = imageModifier, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(childBlock.style), colorFilter = colorFilter, imageLoader = imageLoader ) @@ -4683,10 +8325,15 @@ private fun RenderFlexChildBlock( else textStyle cell.content.forEach { blockInCell -> if (blockInCell is TextContentBlock) { - Text( + LinkAwareText( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback ) } else if (blockInCell is ImageBlock) { AsyncImage( @@ -4696,7 +8343,7 @@ private fun RenderFlexChildBlock( ) ).build(), contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, + contentScale = imageContentScale(blockInCell.style), modifier = tableCellImageModifier( block = blockInCell, density = density, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt index 7e9c340..77d1da8 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt @@ -58,6 +58,7 @@ class PaginatedReaderViewModel : ViewModel() { @VisibleForTesting internal fun setPaginatorForTest(testPaginator: IPaginator) { + paginator?.dispose() paginator = testPaginator observePaginatorState() } @@ -177,4 +178,9 @@ class PaginatedReaderViewModel : ViewModel() { fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) { paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete) } + + override fun onCleared() { + paginator?.dispose() + super.onCleared() + } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt index 078c90b..70394cf 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt @@ -20,12 +20,17 @@ package com.aryan.reader.paginatedreader import android.os.Build +import android.util.Log +import com.aryan.reader.BuildConfig import timber.log.Timber import androidx.annotation.RequiresApi +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -34,11 +39,66 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap +import kotlin.math.ceil import kotlin.math.roundToInt +import kotlin.coroutines.coroutineContext private const val DEBUG_PAGINATION_LOGS = false +private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff" +private const val JustifiedSplitGapProbeMinFraction = 0.18f + +internal fun measuredTextHeightForPagination( + layoutHeightPx: Int, + lastLineBottomPx: Float +): Int { + return maxOf(layoutHeightPx, ceil(lastLineBottomPx.toDouble()).toInt()) +} + +private fun TextLayoutResult.paginationMeasuredHeightPx(): Int { + val lastLineBottomPx = if (lineCount > 0) getLineBottom(lineCount - 1) else 0f + return measuredTextHeightForPagination(size.height, lastLineBottomPx) +} + +private fun logAndroidEpubCutoff(message: String) { + if (!BuildConfig.DEBUG) return + Log.d(AndroidEpubCutoffLogTag, message) +} + +private fun CharSequence.firstWordOrEmpty(): String { + var start = 0 + while (start < length && this[start].isWhitespace()) start++ + if (start >= length) return "" + var end = start + while (end < length && !this[end].isWhitespace()) end++ + return subSequence(start, end).toString() +} + +private fun CharSequence.skipWhitespaceFrom(index: Int): Int { + var current = index.coerceIn(0, length) + while (current < length && this[current].isWhitespace()) current++ + return current +} + +private fun CharSequence.trimTrailingWhitespaceBefore(index: Int): Int { + var current = index.coerceIn(0, length) + while (current > 0 && this[current - 1].isWhitespace()) current-- + return current +} + +private fun CharSequence.nextWordEndAfter(index: Int): Int { + var current = skipWhitespaceFrom(index) + while (current < length && !this[current].isWhitespace()) current++ + return current +} + +private fun CharSequence.previousWordEndBefore(index: Int): Int { + var current = trimTrailingWhitespaceBefore(index) + while (current > 0 && !this[current - 1].isWhitespace()) current-- + return trimTrailingWhitespaceBefore(current) +} interface BlockMeasurementProvider { suspend fun measure(block: ContentBlock): Int @@ -59,6 +119,7 @@ class SuspendingAndroidBlockMeasurementProvider( private val measurementCache = ConcurrentHashMap() override suspend fun measure(block: ContentBlock): Int { + coroutineContext.ensureActive() val cacheKey = blockMeasurementCacheKey(block) measurementCache[cacheKey]?.let { return it } @@ -85,6 +146,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() return splitParagraphBlock( block = block, textMeasurer = textMeasurer, @@ -96,6 +158,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair>? { + coroutineContext.ensureActive() val imageBlock = block.floatedImage val (imageWidthPx, imageHeightPx) = run { @@ -145,6 +208,7 @@ class SuspendingAndroidBlockMeasurementProvider( val wrappingContentWidth = (constraints.maxWidth - imageWidthPx).toInt().coerceAtLeast(0) while (textOffset < fullText.length) { + coroutineContext.ensureActive() val isBesideImage = currentY < imageHeightPx val currentMaxWidth = if (isBesideImage) wrappingContentWidth else constraints.maxWidth @@ -212,6 +276,7 @@ class SuspendingAndroidBlockMeasurementProvider( var splitOccurred = false for ((index, paraRange) in paragraphOffsets.withIndex()) { + coroutineContext.ensureActive() val originalPara = block.paragraphsToWrap[index] if (splitOccurred) { @@ -287,6 +352,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: TableBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() var currentHeight = 0 var splitRowIndex = -1 @@ -304,17 +370,28 @@ class SuspendingAndroidBlockMeasurementProvider( currentHeight += decorationTop for (i in block.rows.indices) { + coroutineContext.ensureActive() val row = block.rows[i] var maxRowHeight = 0 val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) - row.forEach { cell -> + for (cell in row) { + coroutineContext.ensureActive() val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt() - @Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) + val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) var cellHeight = 0 - cell.content.forEach { b -> - cellHeight += measure(b) + for (b in cell.content) { + coroutineContext.ensureActive() + cellHeight += measureBlockHeight( + block = b, + textMeasurer = textMeasurer, + constraints = cellConstraints, + defaultStyle = textStyle, + headerStyle = textStyle.copy(fontWeight = FontWeight.Bold), + density = density, + imageSizeMultiplier = imageSizeMultiplier + ) } val cellDecoration = with(density) { cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() + @@ -346,6 +423,7 @@ class SuspendingAndroidBlockMeasurementProvider( } override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair? { + coroutineContext.ensureActive() if (block.style.flexDirection == "row") return null var currentHeight = 0 @@ -362,6 +440,7 @@ class SuspendingAndroidBlockMeasurementProvider( currentHeight += decorationTop for (i in block.children.indices) { + coroutineContext.ensureActive() val child = block.children[i] val childHeight = measure(child) val margin = with(density) { @@ -422,6 +501,15 @@ private fun setBlockExpectedHeight(block: T, height: Int): T } as T } +private fun BlockStyle.avoidsBreakInside(): Boolean = + pageBreakInsideAvoid || breakInside in setOf("avoid", "avoid-page", "avoid-column") + +private fun BlockStyle.forcesBreakBefore(): Boolean = + breakBefore in setOf("page", "always", "left", "right", "recto", "verso") + +private fun BlockStyle.forcesBreakAfter(): Boolean = + breakAfter in setOf("page", "always", "left", "right", "recto", "verso") + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) suspend fun paginate( blocks: List, @@ -444,8 +532,19 @@ suspend fun paginate( val safetyMarginPerBlock = 0 while (remainingBlocks.isNotEmpty()) { + coroutineContext.ensureActive() val block = remainingBlocks.removeAt(0) + if (currentPageContent.isNotEmpty() && block.style.forcesBreakBefore()) { + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) + pageIndex++ + currentPageContent = mutableListOf() + remainingHeight = pageHeight + remainingBlocks.add(0, block) + continue + } + val blockHeight = measurementProvider.measure(block) val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock @@ -488,6 +587,14 @@ suspend fun paginate( currentPageContent.add(blockToAdd) remainingHeight -= spaceRequired + + if (block.style.forcesBreakAfter() && remainingBlocks.isNotEmpty()) { + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) + pageIndex++ + currentPageContent = mutableListOf() + remainingHeight = pageHeight + } } else { var wasSplit = false val heightForSplitting = remainingHeight - spaceBetweenBlocks @@ -495,7 +602,7 @@ suspend fun paginate( if (heightForSplitting > 50) { when (block) { is ParagraphBlock -> { - if (!block.style.pageBreakInsideAvoid) { + if (!block.style.avoidsBreakInside()) { measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> if (part1.content.isNotEmpty()) { @@ -534,7 +641,7 @@ suspend fun paginate( } is WrappingContentBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) { val collapsedMarginDp = @@ -570,7 +677,7 @@ suspend fun paginate( } is TableBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { @@ -602,7 +709,7 @@ suspend fun paginate( } is FlexContainerBlock -> { - measurementProvider.split(block, heightForSplitting) + if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting) ?.let { (part1, part2) -> val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { @@ -692,6 +799,7 @@ private suspend fun measureBlockHeight( density: Density, imageSizeMultiplier: Float = 1.0f ): Int { + coroutineContext.ensureActive() val boxMetrics = computeBlockBoxMetrics(block, constraints, density) val verticalPaddingPx = boxMetrics.verticalPaddingPx val verticalBorderPx = boxMetrics.verticalBorderPx @@ -705,7 +813,7 @@ private suspend fun measureBlockHeight( text = block.content, style = paragraphStyle, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(paragraphStyle, density) } @@ -718,7 +826,7 @@ private suspend fun measureBlockHeight( text = block.content, style = style, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(style, density) } @@ -731,7 +839,9 @@ private suspend fun measureBlockHeight( ) ?: with(density) { 250.dp.toPx() } val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt() - Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") + } finalHeight } is SpacerBlock -> { @@ -745,7 +855,7 @@ private suspend fun measureBlockHeight( text = block.content, style = quoteStyle, constraints = adjustedConstraints - ).size.height + ).paginationMeasuredHeightPx() } height + centeredTextSafetyPaddingPx(quoteStyle, density) } @@ -759,7 +869,7 @@ private suspend fun measureBlockHeight( text = block.content, style = defaultStyle, constraints = textConstraints - ).size.height + ).paginationMeasuredHeightPx() } val markerImageHeight = if (block.itemMarkerImage != null) { with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() } @@ -771,11 +881,13 @@ private suspend fun measureBlockHeight( } is TableBlock -> { var totalHeight = 0 - block.rows.forEach { row -> + for (row in block.rows) { + coroutineContext.ensureActive() var maxRowHeight = 0 val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) - row.forEach { cell -> + for (cell in row) { + coroutineContext.ensureActive() val cellBlockStyle = cell.style.blockStyle val cellMaxWidth = when { cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() } @@ -844,6 +956,7 @@ private suspend fun measureBlockHeight( // Loop until all text is measured. while (textOffset < fullText.length) { + coroutineContext.ensureActive() val isBesideImage = currentY < imageHeightPx val currentMaxWidth = if (isBesideImage) { wrappingContentWidth @@ -936,11 +1049,19 @@ private suspend fun measureBlockHeight( } } val specifiedHeightDp = block.style.height - val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) { + var finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) { with(density) { specifiedHeightDp.toPx().roundToInt() } } else { (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() } + with(density) { + if (block.style.minHeight.isSpecified) { + finalHeight = finalHeight.coerceAtLeast(block.style.minHeight.toPx().roundToInt()) + } + if (block.style.maxHeight.isSpecified && block.style.overflow in setOf("hidden", "clip", "scroll", "auto")) { + finalHeight = finalHeight.coerceAtMost(block.style.maxHeight.toPx().roundToInt()) + } + } if (DEBUG_PAGINATION_LOGS) { Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight") @@ -948,6 +1069,380 @@ private suspend fun measureBlockHeight( return finalHeight } +private suspend fun logJustifiedSplitGapIfSuspicious( + block: ParagraphBlock, + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + layoutResult: TextLayoutResult, + lastVisibleLine: Int, + splitOffset: Int, + availableTextHeight: Int +) { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified || lastVisibleLine !in 0 until layoutResult.lineCount) return + + val lineStart = layoutResult.getLineStart(lastVisibleLine) + val lineEnd = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true) + if (lineStart >= lineEnd || lineEnd > text.length) return + + val visibleRightPx = (lineStart until lineEnd) + .asSequence() + .filter { !text[it].isWhitespace() } + .mapNotNull { index -> + runCatching { layoutResult.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return + + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return + val visualGapPx = contentWidthPx - visibleRightPx + if (visualGapPx < contentWidthPx * JustifiedSplitGapProbeMinFraction) return + + val nextWord = text.text.subSequence(splitOffset.coerceIn(0, text.length), text.length) + .firstWordOrEmpty() + .take(48) + if (nextWord.isBlank()) return + + val lineText = text.text.substring(lineStart, lineEnd).trimEnd() + val candidateLineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = "$lineText $nextWord", + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + + coroutineContext.ensureActive() + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " + + "line=$lastVisibleLine lineOffsets=$lineStart..$lineEnd splitOffset=$splitOffset " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "contentWidthPx=$contentWidthPx visibleRightPx=${visibleRightPx.roundToInt()} " + + "visualGapPx=${visualGapPx.roundToInt()} availableTextHeightPx=$availableTextHeight " + + "nextWordChars=${nextWord.length} candidateLineCount=$candidateLineCount " + + "note=justify_expands_spaces_so_visual_gap_may_not_be_fit_capacity" + ) +} + +private suspend fun logRenderedJustifiedSplitGapIfSuspicious( + block: ParagraphBlock, + part1Text: AnnotatedString, + part2Text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + originalLayoutResult: TextLayoutResult, + originalLastVisibleLine: Int, + splitOffset: Int, + availableTextHeight: Int +) { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + part1Text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified || part1Text.isEmpty()) return + + val renderedPart1Layout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part1Text, + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val renderedLastLine = renderedPart1Layout.lineCount - 1 + if (renderedLastLine < 0) return + + val renderedLineStart = renderedPart1Layout.getLineStart(renderedLastLine) + val renderedLineEnd = renderedPart1Layout.getLineEnd(renderedLastLine, visibleEnd = true) + if (renderedLineStart >= renderedLineEnd || renderedLineEnd > part1Text.length) return + + val visibleRightPx = (renderedLineStart until renderedLineEnd) + .asSequence() + .filter { !part1Text[it].isWhitespace() } + .mapNotNull { index -> + runCatching { renderedPart1Layout.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return + + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return + val visualGapPx = contentWidthPx - visibleRightPx + val renderedLineText = part1Text.text.substring(renderedLineStart, renderedLineEnd).trim() + val renderedLineWordCount = renderedLineText.split(Regex("\\s+")).count { it.isNotBlank() } + val sparseByGap = visualGapPx >= contentWidthPx * 0.10f + val sparseByWords = renderedLineWordCount <= 4 && visualGapPx >= contentWidthPx * 0.06f + if (!sparseByGap && !sparseByWords) return + + val nextWord = part2Text.text.firstWordOrEmpty().take(48) + if (nextWord.isBlank()) return + + val visualCandidateLineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = "$renderedLineText $nextWord", + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + + val part2LineCount = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part2Text, + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + val nextWordEnd = part2Text.text.nextWordEndAfter(0) + val remainingAfterNextWordStart = part2Text.text.skipWhitespaceFrom(nextWordEnd) + val remainingAfterNextWordLineCount = if (remainingAfterNextWordStart < part2Text.length) { + withContext(Dispatchers.Main) { + textMeasurer.measure( + text = part2Text.subSequence(remainingAfterNextWordStart, part2Text.length), + style = paragraphStyle, + constraints = paragraphConstraints + ).lineCount + } + } else { + 0 + } + + val originalLineStart = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) { + originalLayoutResult.getLineStart(originalLastVisibleLine) + } else { + -1 + } + val originalLineEnd = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) { + originalLayoutResult.getLineEnd(originalLastVisibleLine, visibleEnd = true) + } else { + -1 + } + + coroutineContext.ensureActive() + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "splitOffset=$splitOffset availableTextHeightPx=$availableTextHeight " + + "renderedLines=${renderedPart1Layout.lineCount} renderedLastLine=$renderedLastLine " + + "renderedLineOffsets=$renderedLineStart..$renderedLineEnd " + + "renderedLineChars=${renderedLineText.length} renderedLineWords=$renderedLineWordCount " + + "contentWidthPx=$contentWidthPx renderedVisibleRightPx=${visibleRightPx.roundToInt()} " + + "renderedVisualGapPx=${visualGapPx.roundToInt()} nextWordChars=${nextWord.length} " + + "visualCandidateLineCount=$visualCandidateLineCount part2Lines=$part2LineCount " + + "remainingAfterNextWordLines=$remainingAfterNextWordLineCount " + + "originalLastLine=$originalLastVisibleLine originalLineOffsets=$originalLineStart..$originalLineEnd " + + "note=rendered_split_final_line_is_unjustified_so_gap_can_appear_after_pagination" + ) +} + +private data class RenderedSplitCandidate( + val splitOffset: Int, + val prefixHeightPx: Int, + val prefixLineCount: Int, + val remainingLineCount: Int, + val lastLineChars: Int, + val lastLineWords: Int, + val lastLineVisualGapPx: Int, + val contentWidthPx: Int +) { + val sparseLastLine: Boolean + get() = lastLineVisualGapPx >= contentWidthPx * 0.20f || + (lastLineWords <= 4 && lastLineVisualGapPx >= contentWidthPx * 0.08f) +} + +private fun isBetterRenderedJustifySplitCandidate( + candidate: RenderedSplitCandidate, + current: RenderedSplitCandidate +): Boolean { + if (candidate.sparseLastLine != current.sparseLastLine) { + return !candidate.sparseLastLine + } + if (candidate.sparseLastLine) { + if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) { + return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx + } + return candidate.splitOffset > current.splitOffset + } + if (candidate.prefixLineCount != current.prefixLineCount) { + return candidate.prefixLineCount > current.prefixLineCount + } + if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) { + return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx + } + return candidate.splitOffset > current.splitOffset +} + +private suspend fun measureRenderedSplitCandidate( + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + splitOffset: Int +): RenderedSplitCandidate? { + val prefixEnd = text.text.trimTrailingWhitespaceBefore(splitOffset) + if (prefixEnd <= 0 || prefixEnd >= text.length) return null + + val remainingStart = text.text.skipWhitespaceFrom(prefixEnd) + if (remainingStart >= text.length) return null + + val prefixLayout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = text.subSequence(0, prefixEnd), + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val remainingLayout = withContext(Dispatchers.Main) { + textMeasurer.measure( + text = text.subSequence(remainingStart, text.length), + style = paragraphStyle, + constraints = paragraphConstraints + ) + } + coroutineContext.ensureActive() + + val lastLine = prefixLayout.lineCount - 1 + if (lastLine < 0) return null + val lineStart = prefixLayout.getLineStart(lastLine) + val lineEnd = prefixLayout.getLineEnd(lastLine, visibleEnd = true) + if (lineStart >= lineEnd || lineEnd > prefixEnd) return null + val prefixText = text.text.substring(0, prefixEnd) + val lastLineText = prefixText.substring(lineStart, lineEnd).trim() + val lastLineWords = lastLineText.split(Regex("\\s+")).count { it.isNotBlank() } + val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return null + val visibleRightPx = (lineStart until lineEnd) + .asSequence() + .filter { !prefixText[it].isWhitespace() } + .mapNotNull { index -> + runCatching { prefixLayout.getBoundingBox(index).right }.getOrNull() + } + .maxOrNull() ?: return null + val lastLineVisualGapPx = (contentWidthPx - visibleRightPx).roundToInt() + + return RenderedSplitCandidate( + splitOffset = prefixEnd, + prefixHeightPx = prefixLayout.paginationMeasuredHeightPx(), + prefixLineCount = prefixLayout.lineCount, + remainingLineCount = remainingLayout.lineCount, + lastLineChars = lastLineText.length, + lastLineWords = lastLineWords, + lastLineVisualGapPx = lastLineVisualGapPx, + contentWidthPx = contentWidthPx + ) +} + +private suspend fun adjustJustifiedSplitOffsetForRenderedPrefix( + block: ParagraphBlock, + text: AnnotatedString, + textMeasurer: TextMeasurer, + paragraphStyle: TextStyle, + paragraphConstraints: Constraints, + initialSplitOffset: Int, + availableTextHeight: Int, + orphanLines: Int, + widowLines: Int +): Int? { + coroutineContext.ensureActive() + val isJustified = block.textAlign == TextAlign.Justify || + paragraphStyle.textAlign == TextAlign.Justify || + text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify } + if (!isJustified) return initialSplitOffset + + val normalizedInitialOffset = text.text.trimTrailingWhitespaceBefore(initialSplitOffset) + var candidateOffset = normalizedInitialOffset + var bestCandidate: RenderedSplitCandidate? = null + + while (candidateOffset > 0) { + coroutineContext.ensureActive() + val candidate = measureRenderedSplitCandidate( + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + splitOffset = candidateOffset + ) + if (candidate != null && + candidate.prefixHeightPx <= availableTextHeight && + candidate.prefixLineCount >= orphanLines && + candidate.remainingLineCount >= widowLines + ) { + bestCandidate = candidate + break + } + candidateOffset = text.text.previousWordEndBefore(candidateOffset) + } + + if (bestCandidate == null) { + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "initialSplitOffset=$initialSplitOffset adjustedSplitOffset=null " + + "availableTextHeightPx=$availableTextHeight reason=no_rendered_prefix_fit" + ) + return null + } + + var acceptedCandidate: RenderedSplitCandidate = bestCandidate ?: return null + var furthestFittingCandidate: RenderedSplitCandidate = acceptedCandidate + while (true) { + coroutineContext.ensureActive() + val nextOffset = text.text.nextWordEndAfter(furthestFittingCandidate.splitOffset) + if (nextOffset <= furthestFittingCandidate.splitOffset || nextOffset >= text.length) break + + val nextCandidate = measureRenderedSplitCandidate( + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + splitOffset = nextOffset + ) ?: break + + if (nextCandidate.prefixHeightPx > availableTextHeight || + nextCandidate.prefixLineCount < orphanLines || + nextCandidate.remainingLineCount < widowLines + ) { + break + } + furthestFittingCandidate = nextCandidate + if (isBetterRenderedJustifySplitCandidate(nextCandidate, acceptedCandidate)) { + acceptedCandidate = nextCandidate + } + } + + if (acceptedCandidate.splitOffset != normalizedInitialOffset || + acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset + ) { + val reason = if (acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset) { + "best_rendered_last_line" + } else { + "rendered_prefix_fit" + } + logAndroidEpubCutoff( + "cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " + + "sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " + + "initialSplitOffset=$initialSplitOffset normalizedInitialOffset=$normalizedInitialOffset " + + "adjustedSplitOffset=${acceptedCandidate.splitOffset} availableTextHeightPx=$availableTextHeight " + + "adjustedPrefixHeightPx=${acceptedCandidate.prefixHeightPx} " + + "adjustedPrefixLines=${acceptedCandidate.prefixLineCount} " + + "adjustedRemainingLines=${acceptedCandidate.remainingLineCount} " + + "adjustedLineChars=${acceptedCandidate.lastLineChars} " + + "adjustedLineWords=${acceptedCandidate.lastLineWords} " + + "adjustedLineGapPx=${acceptedCandidate.lastLineVisualGapPx} " + + "furthestFitSplitOffset=${furthestFittingCandidate.splitOffset} " + + "furthestFitLineWords=${furthestFittingCandidate.lastLineWords} " + + "furthestFitLineGapPx=${furthestFittingCandidate.lastLineVisualGapPx} " + + "reason=$reason" + ) + } + + return acceptedCandidate.splitOffset +} + private suspend fun splitParagraphBlock( block: ParagraphBlock, textMeasurer: TextMeasurer, @@ -956,6 +1451,7 @@ private suspend fun splitParagraphBlock( availableHeight: Int, density: Density ): Pair? { + coroutineContext.ensureActive() val text = block.content if (text.isEmpty()) return null val boxMetrics = computeBlockBoxMetrics(block, constraints, density) @@ -992,7 +1488,8 @@ private suspend fun splitParagraphBlock( ) } - if (layoutResult.size.height <= availableTextHeight) { + coroutineContext.ensureActive() + if (layoutResult.paginationMeasuredHeightPx() <= availableTextHeight) { return null } @@ -1010,9 +1507,12 @@ private suspend fun splitParagraphBlock( return null } - if (lastVisibleLine == 0) { + val orphanLines = block.style.orphans.coerceAtLeast(1) + val widowLines = block.style.widows.coerceAtLeast(1) + val visibleLineCount = lastVisibleLine + 1 + if (visibleLineCount < orphanLines) { if (DEBUG_PAGINATION_LOGS) { - Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.") + Timber.d("Orphan control: Preventing split that would leave $visibleLineCount line(s) at the bottom of the page.") } return null } @@ -1028,15 +1528,42 @@ private suspend fun splitParagraphBlock( constraints = paragraphConstraints ) } - if (part2Layout.lineCount == 1) { + coroutineContext.ensureActive() + if (part2Layout.lineCount < widowLines) { if (DEBUG_PAGINATION_LOGS) { - Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.") + Timber.d("Widow control: Adjusting split to keep at least $widowLines line(s) at the top of the next page.") } - lastVisibleLine-- + val linesToMove = widowLines - part2Layout.lineCount + lastVisibleLine -= linesToMove.coerceAtLeast(1) + if (lastVisibleLine + 1 < orphanLines) return null splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true) } } + splitOffset = adjustJustifiedSplitOffsetForRenderedPrefix( + block = block, + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + initialSplitOffset = splitOffset, + availableTextHeight = availableTextHeight, + orphanLines = orphanLines, + widowLines = widowLines + ) ?: return null + + logJustifiedSplitGapIfSuspicious( + block = block, + text = text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + layoutResult = layoutResult, + lastVisibleLine = lastVisibleLine, + splitOffset = splitOffset, + availableTextHeight = availableTextHeight + ) + if (splitOffset <= 0 || splitOffset >= text.length) { return null } @@ -1058,6 +1585,19 @@ private suspend fun splitParagraphBlock( return null } + logRenderedJustifiedSplitGapIfSuspicious( + block = block, + part1Text = part1Text, + part2Text = part2Text, + textMeasurer = textMeasurer, + paragraphStyle = paragraphStyle, + paragraphConstraints = paragraphConstraints, + originalLayoutResult = layoutResult, + originalLastVisibleLine = lastVisibleLine, + splitOffset = splitOffset, + availableTextHeight = availableTextHeight + ) + val part2TextWithoutIndent = buildAnnotatedString { append(part2Text) part2Text.paragraphStyles.firstOrNull { it.start == 0 && it.item.textIndent != null }?.let { styleRange -> @@ -1126,7 +1666,8 @@ private suspend fun calculateContentHeightWithMargins( imageSizeMultiplier: Float = 1.0f ): Int { var totalHeight = 0 - children.forEachIndexed { index, child -> + for ((index, child) in children.withIndex()) { + coroutineContext.ensureActive() val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier) val margin = with(density) { if (index > 0) { @@ -1174,6 +1715,7 @@ private fun computeBlockBoxMetrics( val isBorderBox = block.style.boxSizing == "border-box" val specifiedWidthDp = block.style.width val specifiedMaxWidthDp = block.style.maxWidth + val specifiedMinWidthDp = block.style.minWidth val blockOuterWidthPx = with(density) { var effectiveWidthPx = constraints.maxWidth.toFloat() @@ -1186,6 +1728,9 @@ private fun computeBlockBoxMetrics( effectiveWidthPx = maxWidthPx } } + if (specifiedMinWidthDp != Dp.Unspecified) { + effectiveWidthPx = effectiveWidthPx.coerceAtLeast(specifiedMinWidthDp.toPx()) + } effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) } @@ -1209,7 +1754,7 @@ private fun centeredTextSafetyPaddingPx( style: TextStyle, density: Density ): Int { - if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0 + if (style.textAlign != TextAlign.Center) return 0 val fallbackLineHeight = if (style.fontSize.isSpecified) { style.fontSize * 1.2f diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkDiagnostics.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkDiagnostics.kt new file mode 100644 index 0000000..413228e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkDiagnostics.kt @@ -0,0 +1,247 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +internal const val TAG_PAGINATED_LINK_DIAG = "PaginatedLinkDiag" + +private const val LINK_DIAG_MAX_SAMPLES = 4 +private val linkDiagWhitespaceRegex = Regex("\\s+") + +internal fun Document.readerHtmlLinkDiagSummary(): String { + val linkElements = getElementsByTag("a").mapNotNull { element -> + element.readerHrefForDiagnostics()?.let { href -> element to href } + } + val samples = linkElements.take(LINK_DIAG_MAX_SAMPLES).joinToString( + prefix = "[", + postfix = "]" + ) { (element, href) -> + "href=${href.readerLinkDiagPreview()} text=\"${element.text().readerLinkDiagPreview()}\"" + } + return "htmlAnchors=${linkElements.size} htmlSamples=$samples" +} + +internal fun List.readerSemanticLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEach { it.collectSemanticLinks(collector) } + return collector.semanticSummary() +} + +internal fun List.readerContentLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEach { it.collectContentLinks(collector, pageInChapter = null) } + return collector.contentSummary() +} + +internal fun Page.readerPageLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + content.forEach { it.collectContentLinks(collector, pageInChapter = null) } + return collector.contentSummary() +} + +internal fun List.readerPagesLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + forEachIndexed { pageInChapter, page -> + page.content.forEach { it.collectContentLinks(collector, pageInChapter) } + } + return "pages=$size ${collector.contentSummary()}" +} + +internal fun AnnotatedString.readerAnnotatedLinkDiagSummary(): String { + val collector = ReaderLinkDiagCollector() + collector.addAnnotatedLinks( + blockIndex = null, + blockType = "AnnotatedString", + cfi = null, + text = this, + pageInChapter = null + ) + return collector.contentSummary() +} + +internal fun String.readerLinkDiagPreview(maxLength: Int = 96): String { + val cleaned = replace(linkDiagWhitespaceRegex, " ").trim() + return if (cleaned.length <= maxLength) cleaned else cleaned.take(maxLength - 3) + "..." +} + +private fun Element.readerHrefForDiagnostics(): String? { + return attr("href") + .ifBlank { attr("xlink:href") } + .ifBlank { attr("l:href") } + .ifBlank { attr("epub:href") } + .ifBlank { null } +} + +private class ReaderLinkDiagCollector { + private var semanticTextBlocks = 0 + private var semanticLinkSpans = 0 + private val semanticSamples = mutableListOf() + + private var contentTextBlocks = 0 + private var contentUrlAnnotations = 0 + private var contentLinksWithColor = 0 + private var contentLinksWithBackground = 0 + private var contentLinksWithUnderline = 0 + private var contentLinksWithCoveringStyle = 0 + private val contentSamples = mutableListOf() + + fun addSemanticTextBlock(block: SemanticTextBlock) { + semanticTextBlocks++ + block.spans.forEach { span -> + val href = span.linkHref?.takeIf { it.isNotBlank() } ?: return@forEach + semanticLinkSpans++ + if (semanticSamples.size < LINK_DIAG_MAX_SAMPLES) { + val start = span.start.coerceIn(0, block.text.length) + val end = span.end.coerceIn(start, block.text.length) + semanticSamples += buildString { + append("block=") + append(block.blockIndex) + append(" type=") + append(block::class.simpleName ?: "Text") + append(" tag=") + append(span.tag) + append(" range=") + append(start) + append("..") + append(end) + append(" href=") + append(href.readerLinkDiagPreview()) + append(" text=\"") + append(block.text.substring(start, end).readerLinkDiagPreview()) + append("\"") + } + } + } + } + + fun addAnnotatedLinks( + blockIndex: Int?, + blockType: String, + cfi: String?, + text: AnnotatedString, + pageInChapter: Int? + ) { + contentTextBlocks++ + val annotations = text.getStringAnnotations("URL", 0, text.length) + .filter { it.item.isNotBlank() } + annotations.forEach { annotation -> + contentUrlAnnotations++ + val coverage = text.readerLinkStyleCoverage(annotation) + if (coverage.hasColor) contentLinksWithColor++ + if (coverage.hasBackground) contentLinksWithBackground++ + if (coverage.hasUnderline) contentLinksWithUnderline++ + if (coverage.hasCoveringStyle) contentLinksWithCoveringStyle++ + if (contentSamples.size < LINK_DIAG_MAX_SAMPLES) { + contentSamples += buildString { + if (pageInChapter != null) { + append("pageInChapter=") + append(pageInChapter) + append(" ") + } + append("block=") + append(blockIndex ?: -1) + append(" type=") + append(blockType) + if (!cfi.isNullOrBlank()) { + append(" cfi=") + append(cfi) + } + append(" range=") + append(annotation.start) + append("..") + append(annotation.end) + append(" href=") + append(annotation.item.readerLinkDiagPreview()) + append(" style={color=") + append(coverage.hasColor) + append(",bg=") + append(coverage.hasBackground) + append(",underline=") + append(coverage.hasUnderline) + append(",covering=") + append(coverage.hasCoveringStyle) + append("} text=\"") + append( + text.text.substring( + annotation.start.coerceIn(0, text.length), + annotation.end.coerceIn(annotation.start.coerceIn(0, text.length), text.length) + ).readerLinkDiagPreview() + ) + append("\"") + } + } + } + } + + fun semanticSummary(): String { + return "semanticTextBlocks=$semanticTextBlocks semanticLinkSpans=$semanticLinkSpans semanticSamples=${semanticSamples.joinToString(prefix = "[", postfix = "]")}" + } + + fun contentSummary(): String { + return "contentTextBlocks=$contentTextBlocks urlAnnotations=$contentUrlAnnotations styled={color=$contentLinksWithColor,bg=$contentLinksWithBackground,underline=$contentLinksWithUnderline,covering=$contentLinksWithCoveringStyle} contentSamples=${contentSamples.joinToString(prefix = "[", postfix = "]")}" + } +} + +private data class ReaderLinkStyleCoverage( + val hasColor: Boolean, + val hasBackground: Boolean, + val hasUnderline: Boolean, + val hasCoveringStyle: Boolean +) + +private fun AnnotatedString.readerLinkStyleCoverage( + link: AnnotatedString.Range +): ReaderLinkStyleCoverage { + val overlappingStyles = spanStyles.filter { styleRange -> + styleRange.start < link.end && styleRange.end > link.start + } + return ReaderLinkStyleCoverage( + hasColor = overlappingStyles.any { it.item.color.isSpecified }, + hasBackground = overlappingStyles.any { it.item.background.isSpecified }, + hasUnderline = overlappingStyles.any { + it.item.textDecoration?.contains(TextDecoration.Underline) == true + }, + hasCoveringStyle = overlappingStyles.any { + it.start <= link.start && it.end >= link.end + } + ) +} + +private fun SemanticBlock.collectSemanticLinks(collector: ReaderLinkDiagCollector) { + when (this) { + is SemanticList -> items.forEach { it.collectSemanticLinks(collector) } + is SemanticTable -> rows.flatten().forEach { cell -> + cell.content.forEach { it.collectSemanticLinks(collector) } + } + is SemanticFlexContainer -> children.forEach { it.collectSemanticLinks(collector) } + is SemanticWrappingBlock -> paragraphsToWrap.forEach { it.collectSemanticLinks(collector) } + is SemanticTextBlock -> collector.addSemanticTextBlock(this) + else -> Unit + } +} + +private fun ContentBlock.collectContentLinks( + collector: ReaderLinkDiagCollector, + pageInChapter: Int? +) { + when (this) { + is WrappingContentBlock -> paragraphsToWrap.forEach { + it.collectContentLinks(collector, pageInChapter) + } + is TableBlock -> rows.flatten().forEach { cell -> + cell.content.forEach { it.collectContentLinks(collector, pageInChapter) } + } + is FlexContainerBlock -> children.forEach { it.collectContentLinks(collector, pageInChapter) } + is TextContentBlock -> collector.addAnnotatedLinks( + blockIndex = blockIndex, + blockType = this::class.simpleName ?: "Text", + cfi = cfi, + text = content, + pageInChapter = pageInChapter + ) + else -> Unit + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderNavigationTargets.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderNavigationTargets.kt new file mode 100644 index 0000000..7a37e57 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderNavigationTargets.kt @@ -0,0 +1,112 @@ +package com.aryan.reader.paginatedreader + +import com.aryan.reader.SearchResult + +internal fun flattenTextContentBlocksForNavigation(blocks: List): List { + return blocks.flatMap { block -> + when (block) { + is WrappingContentBlock -> flattenTextContentBlocksForNavigation( + listOf(block.floatedImage) + block.paragraphsToWrap + ) + is FlexContainerBlock -> flattenTextContentBlocksForNavigation(block.children) + is TableBlock -> block.rows.flatten().flatMap { flattenTextContentBlocksForNavigation(it.content) } + is TextContentBlock -> listOf(block) + else -> emptyList() + } + } +} + +internal fun findLocatorForSearchResultInBlocks( + result: SearchResult, + blocks: List +): Locator? { + val query = result.query.takeIf { it.isNotBlank() } ?: return null + var occurrenceCount = 0 + + flattenTextContentBlocksForNavigation(blocks).forEach { block -> + val text = block.content.text + var lastIndex = -1 + while (true) { + lastIndex = text.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true) + if (lastIndex == -1) break + + val isWordStart = lastIndex == 0 || !text[lastIndex - 1].isLetterOrDigit() + if (isWordStart) { + if (occurrenceCount == result.occurrenceIndexInLocation) { + return Locator( + chapterIndex = result.locationInSource, + blockIndex = block.blockIndex, + charOffset = block.startCharOffsetInSource + lastIndex + ) + } + occurrenceCount++ + } + } + } + + return null +} + +internal fun findLocatorForAnchorInBlocks( + chapterIndex: Int, + anchor: String?, + blocks: List +): Locator? { + if (anchor.isNullOrBlank()) return Locator(chapterIndex, 0, 0) + return blocks.asSequence() + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() +} + +private fun findLocatorForAnchorInBlock( + chapterIndex: Int, + anchor: String, + block: ContentBlock +): Locator? { + if (block.elementId == anchor) return locatorForBlockStart(chapterIndex, block) + + if (block is TextContentBlock) { + block.content.getStringAnnotations("ID", 0, block.content.length) + .firstOrNull { it.item == anchor } + ?.let { annotation -> + return Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = block.startCharOffsetInSource + annotation.start + ) + } + } + + return when (block) { + is FlexContainerBlock -> block.children.asSequence() + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + is TableBlock -> block.rows.asSequence() + .flatMap { row -> row.asSequence() } + .flatMap { cell -> cell.content.asSequence() } + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + is WrappingContentBlock -> sequenceOf(block.floatedImage) + .plus(block.paragraphsToWrap.asSequence().map { it as ContentBlock }) + .mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) } + .firstOrNull() + else -> null + } +} + +private fun locatorForBlockStart(chapterIndex: Int, block: ContentBlock): Locator { + val firstText = flattenTextContentBlocksForNavigation(listOf(block)).firstOrNull() + return if (firstText != null) { + Locator( + chapterIndex = chapterIndex, + blockIndex = firstText.blockIndex, + charOffset = firstText.startCharOffsetInSource + ) + } else { + Locator( + chapterIndex = chapterIndex, + blockIndex = block.blockIndex, + charOffset = 0 + ) + } +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt index 821fd86..d0a1e0f 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt @@ -50,11 +50,14 @@ abstract class BookCacheDao { // --- Chapter Operations (Internal Raw Access) --- - @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex") - protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? + @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash") + protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? - @Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC") - protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List + @Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY rowid DESC LIMIT 1") + protected abstract suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? + + @Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash ORDER BY chunk_index ASC") + protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) @@ -65,6 +68,9 @@ abstract class BookCacheDao { @Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId") protected abstract suspend fun deleteChapterMetadataForBook(bookId: String) + @Query("DELETE FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash") + protected abstract suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insertAnchorIndices(anchors: List) @@ -75,12 +81,16 @@ abstract class BookCacheDao { abstract suspend fun deleteAnchorsForBook(bookId: String) @Transaction - open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? { - val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null - val chunks = getChapterChunks(bookId, chapterIndex) + open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int? = null): ProcessedChapter? { + val metadata = if (styleConfigHash == null) { + getAnyChapterMetadata(bookId, chapterIndex) + } else { + getChapterMetadata(bookId, chapterIndex, styleConfigHash) + } ?: return null + val chunks = getChapterChunks(bookId, chapterIndex, metadata.styleConfigHash) if (chunks.isEmpty()) { - return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount) + return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount, metadata.styleConfigHash) } val totalSize = chunks.sumOf { it.size } @@ -95,7 +105,8 @@ abstract class BookCacheDao { bookId = bookId, chapterIndex = chapterIndex, contentBlocksProto = mergedData, - estimatedPageCount = metadata.estimatedPageCount + estimatedPageCount = metadata.estimatedPageCount, + styleConfigHash = metadata.styleConfigHash ) } @@ -107,8 +118,10 @@ abstract class BookCacheDao { val metadata = ProcessedChapterMetadata( bookId = chapter.bookId, chapterIndex = chapter.chapterIndex, - estimatedPageCount = chapter.estimatedPageCount + estimatedPageCount = chapter.estimatedPageCount, + styleConfigHash = chapter.styleConfigHash ) + deleteChapterChunksForChapter(chapter.bookId, chapter.chapterIndex, chapter.styleConfigHash) insertChapterMetadata(metadata) val fullData = chapter.contentBlocksProto @@ -126,6 +139,7 @@ abstract class BookCacheDao { ProcessedChapterChunk( bookId = chapter.bookId, chapterIndex = chapter.chapterIndex, + styleConfigHash = chapter.styleConfigHash, chunkIndex = chunkIndex, chunkData = chunkBytes ) @@ -309,7 +323,7 @@ abstract class BookCacheDao { PageCacheChunk::class, PageIndexEntry::class ], - version = 11, + version = 12, exportSchema = false ) abstract class BookCacheDatabase : RoomDatabase() { @@ -326,7 +340,7 @@ abstract class BookCacheDatabase : RoomDatabase() { BookCacheDatabase::class.java, "book_cache_database" ) - .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_10_11, MIGRATION_11_12) .fallbackToDestructiveMigration(true) .build() INSTANCE = instance @@ -394,5 +408,65 @@ abstract class BookCacheDatabase : RoomDatabase() { ) } } + + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `processed_chapter_chunks` RENAME TO `processed_chapter_chunks_old`" + ) + db.execSQL( + "ALTER TABLE `processed_chapter_metadata` RENAME TO `processed_chapter_metadata_old`" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `processed_chapter_metadata` ( + `book_id` TEXT NOT NULL, + `chapter_index` INTEGER NOT NULL, + `estimated_page_count` INTEGER NOT NULL, + `style_config_hash` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO `processed_chapter_metadata` (`book_id`, `chapter_index`, `estimated_page_count`, `style_config_hash`) + SELECT `book_id`, `chapter_index`, `estimated_page_count`, 0 + FROM `processed_chapter_metadata_old` + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `processed_chapter_chunks` ( + `book_id` TEXT NOT NULL, + `chapter_index` INTEGER NOT NULL, + `style_config_hash` INTEGER NOT NULL DEFAULT 0, + `chunk_index` INTEGER NOT NULL, + `chunk_data` BLOB NOT NULL, + PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`), + FOREIGN KEY(`book_id`, `chapter_index`, `style_config_hash`) + REFERENCES `processed_chapter_metadata`(`book_id`, `chapter_index`, `style_config_hash`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`, `chunk_data`) + SELECT `book_id`, `chapter_index`, 0, `chunk_index`, `chunk_data` + FROM `processed_chapter_chunks_old` + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_processed_chapter_chunks_book_id_chapter_index_style_config_hash` ON `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`)" + ) + db.execSQL( + "DROP TABLE `processed_chapter_chunks_old`" + ) + db.execSQL( + "DROP TABLE `processed_chapter_metadata_old`" + ) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt index 3afc197..f6fe682 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt @@ -25,8 +25,8 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -const val LATEST_PROCESSING_VERSION = 11 -const val LATEST_PAGE_CACHE_VERSION = 3 +const val LATEST_PROCESSING_VERSION = 15 +const val LATEST_PAGE_CACHE_VERSION = 4 @Entity(tableName = "processed_books") data class ProcessedBook( @@ -52,7 +52,8 @@ data class ProcessedChapter( val bookId: String, val chapterIndex: Int, val contentBlocksProto: ByteArray, - val estimatedPageCount: Int + val estimatedPageCount: Int, + val styleConfigHash: Int = 0 ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -62,6 +63,7 @@ data class ProcessedChapter( if (chapterIndex != other.chapterIndex) return false if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false if (estimatedPageCount != other.estimatedPageCount) return false + if (styleConfigHash != other.styleConfigHash) return false return true } @@ -70,6 +72,7 @@ data class ProcessedChapter( result = 31 * result + chapterIndex result = 31 * result + contentBlocksProto.contentHashCode() result = 31 * result + estimatedPageCount + result = 31 * result + styleConfigHash return result } } @@ -77,11 +80,12 @@ data class ProcessedChapter( /** * Database Entity: Stores metadata only (small size). */ -@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"]) +@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index", "style_config_hash"]) data class ProcessedChapterMetadata( @ColumnInfo(name = "book_id") val bookId: String, @ColumnInfo(name = "chapter_index") val chapterIndex: Int, - @ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int + @ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int, + @ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0 ) /** @@ -89,20 +93,21 @@ data class ProcessedChapterMetadata( */ @Entity( tableName = "processed_chapter_chunks", - primaryKeys = ["book_id", "chapter_index", "chunk_index"], + primaryKeys = ["book_id", "chapter_index", "style_config_hash", "chunk_index"], foreignKeys = [ ForeignKey( entity = ProcessedChapterMetadata::class, - parentColumns = ["book_id", "chapter_index"], - childColumns = ["book_id", "chapter_index"], + parentColumns = ["book_id", "chapter_index", "style_config_hash"], + childColumns = ["book_id", "chapter_index", "style_config_hash"], onDelete = ForeignKey.CASCADE ) ], - indices = [Index(value = ["book_id", "chapter_index"])] + indices = [Index(value = ["book_id", "chapter_index", "style_config_hash"])] ) data class ProcessedChapterChunk( @ColumnInfo(name = "book_id") val bookId: String, @ColumnInfo(name = "chapter_index") val chapterIndex: Int, + @ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0, @ColumnInfo(name = "chunk_index") val chunkIndex: Int, @ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray ) { @@ -112,6 +117,7 @@ data class ProcessedChapterChunk( other as ProcessedChapterChunk if (bookId != other.bookId) return false if (chapterIndex != other.chapterIndex) return false + if (styleConfigHash != other.styleConfigHash) return false if (chunkIndex != other.chunkIndex) return false if (!chunkData.contentEquals(other.chunkData)) return false return true @@ -120,6 +126,7 @@ data class ProcessedChapterChunk( override fun hashCode(): Int { var result = bookId.hashCode() result = 31 * result + chapterIndex + result = 31 * result + styleConfigHash result = 31 * result + chunkIndex result = 31 * result + chunkData.contentHashCode() return result diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt index 11165da..93e4bac 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt @@ -31,11 +31,14 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.aryan.reader.applyBookReplacementsToHtmlDocument import com.aryan.reader.epub.epubContentFilePath import com.aryan.reader.paginatedreader.CssParser +import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver import com.aryan.reader.paginatedreader.FontFaceInfo import com.aryan.reader.paginatedreader.MathMLRenderer import com.aryan.reader.paginatedreader.OptimizedCssRules @@ -43,9 +46,12 @@ import com.aryan.reader.paginatedreader.RenderResult import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks import com.aryan.reader.paginatedreader.loadFontFamilies import com.aryan.reader.paginatedreader.semanticBlockModule +import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -56,8 +62,8 @@ import kotlinx.serialization.protobuf.ProtoNumber import org.jsoup.Jsoup import org.jsoup.nodes.Element import java.io.File -import java.net.URLDecoder import kotlin.math.abs +import kotlin.coroutines.coroutineContext @OptIn(ExperimentalSerializationApi::class) @Serializable @@ -78,7 +84,10 @@ data class BookProcessingInput( @ProtoNumber(5) val density: Float, @ProtoNumber(6) val constraintsMaxWidth: Int, @ProtoNumber(7) val constraintsMaxHeight: Int, - @ProtoNumber(8) val fontFaces: List = emptyList() + @ProtoNumber(8) val fontFaces: List = emptyList(), + @ProtoNumber(9) val styleConfigHash: Int = 0, + @ProtoNumber(10) val bookReplacementPreferencesJson: String = "", + @ProtoNumber(11) val bookReplacementFileId: String = "" ) @OptIn(ExperimentalSerializationApi::class) @@ -95,6 +104,13 @@ class BookProcessingWorker( private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages" private const val KEY_START_CHAPTER_INDEX = "startChapterIndex" + private fun uniqueWorkName(bookId: String): String = "process_$bookId" + + fun cancelForBook(context: Context, bookId: String) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName(bookId)) + Timber.i("Cancelled stale background processing for book: $bookId") + } + fun enqueue( context: Context, bookId: String, @@ -121,11 +137,11 @@ class BookProcessingWorker( .build() WorkManager.getInstance(context).enqueueUniqueWork( - "process_$bookId", - androidx.work.ExistingWorkPolicy.KEEP, + uniqueWorkName(bookId), + ExistingWorkPolicy.REPLACE, workRequest ) - Timber.i("Enqueued background processing for book: $bookId") + Timber.i("Enqueued latest background processing for book: $bookId config=${processingInput.styleConfigHash}") } } @@ -137,7 +153,6 @@ class BookProcessingWorker( Timber.i("Starting pre-scan to calculate image dimensions...") for (chapter in chapters) { val document = Jsoup.parse(chapter.htmlContent) - val chapterParentPath = File(chapter.absPath).parent ?: "" // Find all image tags (both and ) document.select("img, image").forEach { element -> @@ -145,14 +160,10 @@ class BookProcessingWorker( val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") } if (src.isNotBlank()) { - val decodedSrc = try { - URLDecoder.decode(src, "UTF-8") - } catch (_: Exception) { - src - } - - val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile - val imagePath = imageFile.absolutePath + val imagePath = AndroidHtmlResourceResolver + .resolvePath(chapter.absPath, extractionBasePath, src) + ?: return@forEach + val imageFile = File(imagePath) // If not already cached, read dimensions from disk if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) { @@ -196,6 +207,9 @@ class BookProcessingWorker( return@withContext Result.failure() } val input = proto.decodeFromByteArray(inputFile.readBytes()) + val bookReplacementPreferences = ReaderBookReplacementPreferencesJson.decodeOrEmpty( + input.bookReplacementPreferencesJson, + ) Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}") // Worker now reconstructs everything it needs for a pure light-theme processing run. @@ -240,11 +254,13 @@ class BookProcessingWorker( Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.") chaptersToProcess.chunked(numCores).forEach { chunk -> + coroutineContext.ensureActive() Timber.d("Processing a chunk of ${chunk.size} chapters.") val deferreds = chunk.map { (index, chapter) -> async { + coroutineContext.ensureActive() Timber.d("Async task started for chapter index $index.") - if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) { + if (db.bookCacheDao().getProcessedChapter(bookId, index, input.styleConfigHash) == null) { Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}") val htmlToParse = chapter.htmlContent.ifBlank { val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath)) @@ -290,6 +306,12 @@ class BookProcessingWorker( } Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}") } + applyBookReplacementsToHtmlDocument( + document = document, + preferences = bookReplacementPreferences, + fileId = input.bookReplacementFileId, + ) + coroutineContext.ensureActive() val processedHtml = document.outerHtml() Timber.d("Chapter $index (Background Worker): Processed HTML contains : ${processedHtml.contains("math-placeholder")}") @@ -306,12 +328,14 @@ class BookProcessingWorker( imageDimensionsCache = imageDimensionsCache, mathSvgCache = svgResults ) + coroutineContext.ensureActive() val protoBytes = proto.encodeToByteArray(semanticBlocks) ProcessedChapter( bookId = bookId, chapterIndex = index, contentBlocksProto = protoBytes, - estimatedPageCount = estimateSemanticPageCount(semanticBlocks) + estimatedPageCount = estimateSemanticPageCount(semanticBlocks), + styleConfigHash = input.styleConfigHash ) } else { Timber.d("Chapter $index was already in the database. Skipping.") @@ -341,6 +365,9 @@ class BookProcessingWorker( Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.") return@withContext Result.success() + } catch (e: CancellationException) { + Timber.i("Background processing cancelled for book $bookId") + throw e } catch (e: Exception) { Timber.e(e, "Error in pagination worker for book $bookId") return@withContext Result.failure() diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index e41b138..e840f3c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -96,7 +96,11 @@ import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrLine import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrSymbol +import com.aryan.reader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment +import com.aryan.reader.shared.pdf.pdfCommentChildren +import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments +import com.aryan.reader.shared.pdf.withoutPdfCommentThread import timber.log.Timber import java.text.DateFormat import java.util.Date @@ -708,8 +712,6 @@ private enum class PdfAnnotationSheetSection { COMMENTS } -private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader" - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PdfAnnotationBottomSheet( @@ -740,7 +742,7 @@ fun PdfAnnotationBottomSheet( highlight.comments .lastOrNull { it.author.isNotBlank() } ?.author - ?: DEFAULT_PDF_COMMENT_AUTHOR + ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR ) } @@ -844,14 +846,14 @@ fun PdfAnnotationBottomSheet( editingCommentId = comment.id replyTargetId = null commentText = comment.contents - commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } }, onCancelEdit = { editingCommentId = null commentText = "" }, onDelete = { comment -> - val nextComments = comments.withoutCommentThread(comment.id) + val nextComments = comments.withoutPdfCommentThread(comment.id) persistComments(nextComments) if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { replyTargetId = null @@ -865,7 +867,7 @@ fun PdfAnnotationBottomSheet( val contents = commentText.trim() if (contents.isNotBlank()) { val now = System.currentTimeMillis() - val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } val nextComments = if (editingCommentId != null) { comments.map { comment -> if (comment.id == editingCommentId) { @@ -1005,16 +1007,7 @@ private fun PdfHighlightCommentsEditor( onDelete: (SharedPdfAnnotationComment) -> Unit, onAddComment: () -> Unit ) { - val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet() - val visibleComments = comments - .filter { it.contents.isNotBlank() } - .map { comment -> - if (comment.parentId != null && comment.parentId !in commentIds) { - comment.copy(parentId = null) - } else { - comment - } - } + val visibleComments = comments.visiblePdfAnnotationComments() val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } @@ -1048,7 +1041,7 @@ private fun PdfHighlightCommentsEditor( } else { stringResource( R.string.label_replying_to, - replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty() + replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() ) }, style = MaterialTheme.typography.labelMedium, @@ -1115,8 +1108,7 @@ private fun PdfHighlightCommentThread( onDelete: (SharedPdfAnnotationComment) -> Unit ) { comments - .filter { it.parentId == parentId } - .sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id })) + .pdfCommentChildren(parentId) .forEach { comment -> if (comment.id in visitedIds) return@forEach PdfHighlightCommentItem( @@ -1167,7 +1159,7 @@ private fun PdfHighlightCommentItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }, + text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -1216,19 +1208,6 @@ private fun pdfAnnotationTextFieldColors(effectiveText: Color) = unfocusedTextColor = effectiveText ) -private fun List.withoutCommentThread(commentId: String): List { - val childrenByParentId = groupBy { it.parentId } - val idsToRemove = mutableSetOf() - - fun collect(id: String) { - if (!idsToRemove.add(id)) return - childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) } - } - - collect(commentId) - return filterNot { it.id in idsToRemove } -} - private fun Long.formatPdfCommentTimestamp(): String { if (this <= 0L) return "" return runCatching { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt index a42e8af..998d4ad 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt @@ -1,13 +1,10 @@ package com.aryan.reader.pdf -import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility 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.foundation.BorderStroke -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation @@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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 @@ -38,10 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -195,42 +188,6 @@ internal fun PageScrubbingAnimation( } } -@Composable -internal fun ThumbnailWithIndicator( - thumbnail: Bitmap, - modifier: Modifier = Modifier, - borderColor: Color = Color.Unspecified, - onClick: () -> Unit -) { - val effectiveBorderColor = if (borderColor == Color.Unspecified) { - MaterialTheme.colorScheme.primary - } else { - borderColor - } - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Surface( - modifier = Modifier - .width(45.dp) - .height(64.dp) - .clickable(onClick = onClick), - shape = RoundedCornerShape(4.dp), - border = BorderStroke(2.dp, effectiveBorderColor) - ) { - Image( - bitmap = thumbnail.asImageBitmap(), - contentDescription = stringResource(R.string.content_desc_start_page_thumbnail), - contentScale = ContentScale.FillBounds, - modifier = Modifier.fillMaxSize() - ) - } - Box(modifier = Modifier - .offset(y = (-4).dp) - .size(8.dp) - .rotate(45f) - .background(effectiveBorderColor)) - } -} - @Composable internal fun BookmarkButton( isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier 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 de8d13e..6efc966 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -5713,10 +5713,11 @@ fun PdfRichTextLayer( val selection = tfv.selection @Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) { - val localStart = selection.start.coerceIn(0, textToRender.length) - val localEnd = selection.end.coerceIn(0, textToRender.length) - - if (localStart != localEnd) { + androidPdfRichTextSelectionBounds( + selectionStart = selection.start, + selectionEnd = selection.end, + textLength = textToRender.length + )?.let { (localStart, localEnd) -> val selectionPath = measureResult.getPathForRange(localStart, localEnd) Canvas(modifier = Modifier.fillMaxSize()) { drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f)) @@ -5724,6 +5725,7 @@ fun PdfRichTextLayer( } if (selection.collapsed && controller.isCursorVisible) { + val localStart = selection.start.coerceIn(0, textToRender.length) val alpha = if (isScrolling) { 1f } else { 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 30952c6..5b0b9f4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -115,12 +115,48 @@ private fun sanitizePdfToolNameSet( }.toSet() } +internal fun sanitizePdfHiddenToolNames(toolNames: Collection): Set { + return sanitizePdfToolNameSet(toolNames.toSet()) +} + +internal fun sanitizePdfBottomToolNames(toolNames: Collection): Set { + return sanitizePdfToolNameSet( + toolNames = toolNames.toSet(), + includeTool = ::isPdfToolbarPlacementTool + ) +} + +internal fun restorePdfToolOrderNames(toolNames: Collection): List { + val savedTools = toolNames + .mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } + .filter(::isPdfReaderToolAvailable) + return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() +} + +internal fun isPdfToolbarPlacementTool(tool: PdfReaderTool): Boolean { + return when (tool) { + PdfReaderTool.DICTIONARY, + PdfReaderTool.THEME, + PdfReaderTool.BRIGHTNESS, + PdfReaderTool.LOCK_PANNING, + PdfReaderTool.SLIDER, + PdfReaderTool.TOC, + PdfReaderTool.SEARCH, + PdfReaderTool.HIGHLIGHT_ALL, + PdfReaderTool.AI_FEATURES, + PdfReaderTool.EDIT_MODE, + PdfReaderTool.TTS_CONTROLS, + PdfReaderTool.SCREEN_ORIENTATION -> true + else -> false + } +} + internal fun loadPdfHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()) + val savedHiddenTools = sanitizePdfHiddenToolNames(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 = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion)) + val migratedHiddenTools = sanitizePdfHiddenToolNames(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion)) prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -143,28 +179,27 @@ private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set { internal fun savePdfHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { - putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools)) + putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfHiddenToolNames(hiddenTools)) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) } } internal fun loadPdfToolOrder(context: Context): List { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null) + val savedToolNames = prefs.getString(PDF_TOOL_ORDER_KEY, null) ?.split(',') ?.filter { it.isNotBlank() } - ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } - ?.filter(::isPdfReaderToolAvailable) .orEmpty() - return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() + return restorePdfToolOrderNames(savedToolNames) } internal fun savePdfToolOrder(context: Context, toolOrder: List) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val sanitizedOrder = restorePdfToolOrderNames(toolOrder.map { it.name }) prefs.edit { putString( PDF_TOOL_ORDER_KEY, - toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name } + sanitizedOrder.joinToString(",") { it.name } ) } } @@ -172,22 +207,15 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List) internal fun loadPdfBottomTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val defaultBottomTools = defaultPdfBottomTools() - return sanitizePdfToolNameSet( - toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools, - includeTool = { it.category == "Bottom Bar" } - ) + val savedBottomTools = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, null) ?: return defaultBottomTools + val sanitizedBottomTools = sanitizePdfBottomToolNames(savedBottomTools) + return if (savedBottomTools.isNotEmpty() && sanitizedBottomTools.isEmpty()) defaultBottomTools else sanitizedBottomTools } internal fun savePdfBottomTools(context: Context, bottomTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { - putStringSet( - PDF_BOTTOM_TOOLS_KEY, - sanitizePdfToolNameSet( - toolNames = bottomTools, - includeTool = { it.category == "Bottom Bar" } - ) - ) + putStringSet(PDF_BOTTOM_TOOLS_KEY, sanitizePdfBottomToolNames(bottomTools)) } } 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 e0fa480..e34ac6e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -116,25 +116,17 @@ fun sanitizePdfPlaceholders(list: List): List return result } -private val pdfReorderableToolbarTools = setOf( - PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING, - PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, - PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, - PdfReaderTool.SCREEN_ORIENTATION -) - internal fun buildPdfToolbarItems( hiddenTools: Set, toolOrder: List, bottomTools: Set ): List { val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable) - val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools } + val toolbarTools = availableToolOrder.filter(::isPdfToolbarPlacementTool) val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } - val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools } + val moreTools = availableToolOrder.filterNot(::isPdfToolbarPlacementTool) val list = mutableListOf() @@ -209,7 +201,7 @@ fun PdfCustomizeToolsSheet( val commitDragDrop = { val newHidden = localHiddenTools.filter { toolName -> - toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools + toolOrder.find { it.name == toolName }?.let(::isPdfToolbarPlacementTool) != true }.toMutableSet() val newBottom = mutableSetOf() 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 fc6ad15..8c9abac 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -50,21 +50,6 @@ import kotlin.collections.isNotEmpty internal val PdfTabStripHeight = 44.dp -private val pdfToolbarTools = setOf( - PdfReaderTool.DICTIONARY, - PdfReaderTool.THEME, - PdfReaderTool.BRIGHTNESS, - PdfReaderTool.LOCK_PANNING, - PdfReaderTool.SLIDER, - PdfReaderTool.TOC, - PdfReaderTool.SEARCH, - PdfReaderTool.HIGHLIGHT_ALL, - PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, - PdfReaderTool.TTS_CONTROLS, - PdfReaderTool.SCREEN_ORIENTATION -) - internal enum class PdfOverflowMenuSection { CUSTOMIZE_TOOLBAR, HIDDEN_TOOLS, @@ -243,6 +228,8 @@ internal fun PdfTopBar( totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page) else -> stringResource(R.string.pdf_viewer) } + val topToolbarTools = toolOrder + .filter { isPdfToolbarPlacementTool(it) && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } Text( text = titleText, style = MaterialTheme.typography.titleMedium, @@ -251,117 +238,126 @@ internal fun PdfTopBar( modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator") ) - toolOrder - .filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - .forEach { tool -> - when (tool) { - PdfReaderTool.THEME -> TooltipIconButton( - text = stringResource(R.string.tooltip_theme), - description = stringResource(R.string.tooltip_theme_desc), - onClick = onShowThemePanel - ) { - Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.BRIGHTNESS -> TooltipIconButton( - text = stringResource(R.string.reader_brightness_title), - description = stringResource(R.string.reader_brightness_system_desc), - onClick = onShowBrightnessControl - ) { - Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.LOCK_PANNING -> TooltipIconButton( - text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), - description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc), - onClick = onToggleScrollLock - ) { - Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.DICTIONARY -> TooltipIconButton( - text = stringResource(R.string.tooltip_dictionary), - description = stringResource(R.string.tooltip_dictionary_desc), - onClick = onShowDictionarySettings - ) { - Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.SLIDER -> TooltipIconButton( - text = stringResource(R.string.tooltip_slider), - description = stringResource(R.string.tooltip_slider_desc), - onClick = onShowSlider, - enabled = !isTtsPlayingOrLoading - ) { - Icon( - painterResource(id = R.drawable.slider), - contentDescription = stringResource(R.string.content_desc_navigate_slider), - tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - PdfReaderTool.TOC -> TooltipIconButton( - text = stringResource(R.string.tooltip_toc), - description = stringResource(R.string.tooltip_toc_desc), - onClick = onShowToc, - enabled = !isTtsPlayingOrLoading - ) { - Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents)) - } - PdfReaderTool.SEARCH -> TooltipIconButton( - text = stringResource(R.string.tooltip_search), - description = stringResource(R.string.tooltip_search_desc), - onClick = onSearchClick, - enabled = !isTtsPlayingOrLoading - ) { - Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) - } - PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton( - text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights), - description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc), - onClick = onToggleHighlights - ) { - if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp)) - else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = onShowAiHub + if (topToolbarTools.isNotEmpty() || BuildConfig.DEBUG) { + val topToolbarScrollState = rememberScrollState() + Row( + modifier = Modifier + .weight(1f, fill = false) + .horizontalScroll(topToolbarScrollState), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + topToolbarTools.forEach { tool -> + when (tool) { + PdfReaderTool.THEME -> TooltipIconButton( + text = stringResource(R.string.tooltip_theme), + description = stringResource(R.string.tooltip_theme_desc), + onClick = onShowThemePanel ) { - Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai)) + Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onShowBrightnessControl + ) { + Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.LOCK_PANNING -> TooltipIconButton( + text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), + description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc), + onClick = onToggleScrollLock + ) { + Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.DICTIONARY -> TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = onShowDictionarySettings + ) { + Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.SLIDER -> TooltipIconButton( + text = stringResource(R.string.tooltip_slider), + description = stringResource(R.string.tooltip_slider_desc), + onClick = onShowSlider, + enabled = !isTtsPlayingOrLoading + ) { + Icon( + painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + PdfReaderTool.TOC -> TooltipIconButton( + text = stringResource(R.string.tooltip_toc), + description = stringResource(R.string.tooltip_toc_desc), + onClick = onShowToc, + enabled = !isTtsPlayingOrLoading + ) { + Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents)) + } + PdfReaderTool.SEARCH -> TooltipIconButton( + text = stringResource(R.string.tooltip_search), + description = stringResource(R.string.tooltip_search_desc), + onClick = onSearchClick, + enabled = !isTtsPlayingOrLoading + ) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) + } + PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton( + text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights), + description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc), + onClick = onToggleHighlights + ) { + if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp)) + else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = onShowAiHub + ) { + Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai)) + } + } + PdfReaderTool.EDIT_MODE -> TooltipIconButton( + text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode), + description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc), + onClick = onToggleEditMode + ) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } + PdfReaderTool.TTS_CONTROLS -> TooltipIconButton( + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc), + onClick = onToggleTts + ) { + 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 } - PdfReaderTool.EDIT_MODE -> TooltipIconButton( - text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode), - description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc), - onClick = onToggleEditMode - ) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - PdfReaderTool.TTS_CONTROLS -> TooltipIconButton( - text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc), - onClick = onToggleTts - ) { - 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.DEBUG) { - TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) { - Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary) - } - TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { - Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary) - } - TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) { - Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63)) + if (BuildConfig.DEBUG) { + TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) { + Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary) + } + TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { + Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary) + } + TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) { + Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63)) + } + } } } @@ -394,7 +390,7 @@ internal fun PdfTopBar( showMoreMenu = false } ) { - val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) } + val hiddenToolbarTools = toolOrder.filter { isPdfToolbarPlacementTool(it) && hiddenTools.contains(it.name) } val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) @@ -970,7 +966,7 @@ fun PdfBottomBar( horizontalArrangement = Arrangement.SpaceEvenly ) { toolOrder - .filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + .filter { isPdfToolbarPlacementTool(it) && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } .forEach { tool -> when (tool) { PdfReaderTool.THEME -> TooltipIconButton( 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 66e9a60..9b72a08 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -95,6 +95,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Close @@ -114,7 +116,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -213,6 +214,7 @@ import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiFeature import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.BuildConfig +import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES import com.aryan.reader.FileType import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.MainViewModel @@ -246,6 +248,7 @@ import com.aryan.reader.loadReaderBrightnessSettings import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadReaderSliderToggled import com.aryan.reader.loadTtsReplacementPreferences +import com.aryan.reader.logCloudAnnotationSyncTrace import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.data.AnnotationSettingsRepository @@ -260,6 +263,7 @@ import com.aryan.reader.pdf.data.TextStyleConfig import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.readerSliderBookmarkPosition import com.aryan.reader.readerSliderChromeColors +import com.aryan.reader.readerSliderStepPage import com.aryan.reader.readerSliderToggleState import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes @@ -273,11 +277,16 @@ import com.aryan.reader.scaledToCanvasLimit import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.pdf.PdfSpreadLayout import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.ui.ReaderMinimalSlider import com.aryan.reader.shouldRenderReaderSlider import com.aryan.reader.summarizationUrl +import com.aryan.reader.tts.ReaderTtsOverlaySize import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager +import com.aryan.reader.tts.loadReaderTtsOverlaySize +import com.aryan.reader.tts.readerTtsOverlayAlignmentBias import com.aryan.reader.tts.rememberTtsController +import com.aryan.reader.tts.saveReaderTtsOverlaySize import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.withTtsReplacements import io.legere.pdfiumandroid.suspend.PdfDocumentKt @@ -387,23 +396,41 @@ fun PdfViewerScreen( var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) } var showCustomizeToolsSheet by remember { mutableStateOf(false) } - var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) } - var toolOrder by remember { mutableStateOf(loadPdfToolOrder(context)) } - var bottomTools by remember { mutableStateOf(loadPdfBottomTools(context)) } + var hiddenToolNames by rememberSaveable { + mutableStateOf(loadPdfHiddenTools(context).toList()) + } + var toolOrderNames by rememberSaveable { + mutableStateOf(loadPdfToolOrder(context).map { it.name }) + } + var bottomToolNames by rememberSaveable { + mutableStateOf(loadPdfBottomTools(context).toList()) + } + val hiddenTools = remember(hiddenToolNames) { + sanitizePdfHiddenToolNames(hiddenToolNames) + } + val toolOrder = remember(toolOrderNames) { + restorePdfToolOrderNames(toolOrderNames) + } + val bottomTools = remember(bottomToolNames) { + sanitizePdfBottomToolNames(bottomToolNames) + } val onUpdateHiddenTools = { newSet: Set -> - hiddenTools = newSet - savePdfHiddenTools(context, newSet) + val sanitized = sanitizePdfHiddenToolNames(newSet) + hiddenToolNames = sanitized.toList() + savePdfHiddenTools(context, sanitized) } val onUpdateToolOrder = { newOrder: List -> - toolOrder = newOrder - savePdfToolOrder(context, newOrder) + val sanitized = restorePdfToolOrderNames(newOrder.map { it.name }) + toolOrderNames = sanitized.map { it.name } + savePdfToolOrder(context, sanitized) } val onUpdateBottomTools = { newBottomTools: Set -> - bottomTools = newBottomTools - savePdfBottomTools(context, newBottomTools) + val sanitized = sanitizePdfBottomToolNames(newBottomTools) + bottomToolNames = sanitized.toList() + savePdfBottomTools(context, sanitized) } val isOss = BuildConfig.FLAVOR == "oss" @@ -427,7 +454,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 + val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES var showNewTabSheet by remember { mutableStateOf(false) } var showFileInfoDialog by remember { mutableStateOf(false) } @@ -477,7 +504,7 @@ fun PdfViewerScreen( var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } var isAutoScrollCollapsed by remember { mutableStateOf(false) } - var isTtsCollapsed by remember { mutableStateOf(false) } + var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) } var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } @@ -1339,22 +1366,50 @@ fun PdfViewerScreen( saveMutex.withLock { withContext(Dispatchers.IO) { @Suppress("VariableNeverRead") var didSave = false + var sidecarsSaved = false if (canSaveSidecarsSnapshot) { - if (force || annotsHash != lastSavedHashes[0]) { + if (annotsHash != lastSavedHashes[0]) { + logCloudAnnotationSyncTrace { + "android.reader.save_ink book=$bookId force=$force oldHash=${lastSavedHashes[0]} " + + "newHash=$annotsHash pages=${annots.keys.sorted()} count=${annots.values.sumOf { it.size }}" + } annotationRepository.saveAnnotations(bookId, annots) lastSavedHashes[0] = annotsHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_ink_noop book=$bookId force=true hash=$annotsHash" + } } - if (force || boxesHash != lastSavedHashes[1]) { + if (boxesHash != lastSavedHashes[1]) { + logCloudAnnotationSyncTrace { + "android.reader.save_textboxes book=$bookId force=$force oldHash=${lastSavedHashes[1]} " + + "newHash=$boxesHash count=${boxes.size}" + } textBoxRepository.saveTextBoxes(bookId, boxes) lastSavedHashes[1] = boxesHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_textboxes_noop book=$bookId force=true hash=$boxesHash" + } } - if (force || highlightsHash != lastSavedHashes[2]) { + if (highlightsHash != lastSavedHashes[2]) { + logCloudAnnotationSyncTrace { + "android.reader.save_highlights book=$bookId force=$force oldHash=${lastSavedHashes[2]} " + + "newHash=$highlightsHash count=${highlights.size}" + } highlightRepository.saveHighlights(bookId, highlights) lastSavedHashes[2] = highlightsHash didSave = true + sidecarsSaved = true + } else if (force) { + logCloudAnnotationSyncTrace { + "android.reader.save_highlights_noop book=$bookId force=true hash=$highlightsHash" + } } } else { Timber.tag("PdfTabSync").d( @@ -1384,6 +1439,12 @@ fun PdfViewerScreen( } lastSavedHashes[4] = page } + if (sidecarsSaved) { + logCloudAnnotationSyncTrace { + "android.reader.sidecar_upload_queue book=$bookId force=$force" + } + viewModel.queuePdfSidecarCloudUpload(bookId) + } } } } @@ -1391,6 +1452,44 @@ fun PdfViewerScreen( } } + val persistInkAnnotationsNow = remember(currentBookId, annotationRepository) { + { annotationsSnapshot: Map>, deletedAnnotations: Collection, reason: String -> + val bookIdSnapshot = currentBookId + val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId + val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook( + bookIdSnapshot, + loadedSidecarBookIdSnapshot, + currentAreAnnotationsLoaded + ) + viewModel.viewModelScope.launch { + val bookId = bookIdSnapshot ?: return@launch + if (!canSaveSidecarsSnapshot) { + logCloudAnnotationSyncTrace { + "android.reader.persist_ink_skip book=$bookId reason=$reason loadedSidecarBook=$loadedSidecarBookIdSnapshot" + } + return@launch + } + val deletedIds = deletedAnnotations.mapNotNull { it.id.takeIf(String::isNotBlank) }.toSet() + withContext(NonCancellable) { + saveMutex.withLock { + withContext(Dispatchers.IO) { + if (deletedIds.isNotEmpty()) { + annotationRepository.markAnnotationsDeleted(bookId, deletedIds) + } + annotationRepository.saveAnnotations(bookId, annotationsSnapshot) + lastSavedHashes[0] = annotationsSnapshot.hashCode() + } + } + } + logCloudAnnotationSyncTrace { + "android.reader.persist_ink book=$bookId reason=$reason count=${annotationsSnapshot.values.sumOf { it.size }} " + + "deletedIds=${deletedIds.sorted()}" + } + viewModel.queuePdfSidecarCloudUpload(bookId) + } + } + } + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { @@ -2477,8 +2576,16 @@ fun PdfViewerScreen( allAnnotations = loaded textBoxes.addAll(loadedBoxes) userHighlights.addAll(loadedHighlights) + lastSavedHashes[0] = loaded.hashCode() + lastSavedHashes[1] = loadedBoxes.hashCode() + lastSavedHashes[2] = loadedHighlights.hashCode() loadedSidecarBookId = loadingBookId areAnnotationsLoaded = true + logCloudAnnotationSyncTrace { + "android.reader.sidecar_load book=$loadingBookId inkPages=${loaded.keys.sorted()} " + + "inkCount=${loaded.values.sumOf { it.size }} textBoxes=${loadedBoxes.size} " + + "highlights=${loadedHighlights.size} hashes=${lastSavedHashes.copyOfRange(0, 3).joinToString()}" + } Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " + "textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}" @@ -2646,7 +2753,6 @@ fun PdfViewerScreen( var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } - var startPageThumbnail by remember { mutableStateOf(null) } val pdfSliderChromeVisible = shouldRenderReaderSlider( isToggledOn = isPageSliderVisible, isBottomChromeVisible = showStandardBars, @@ -3342,23 +3448,6 @@ fun PdfViewerScreen( } } - LaunchedEffect(pdfSliderChromeVisible, sliderStartPage, pdfDocument, totalPages) { - startPageThumbnail?.recycle() - startPageThumbnail = null - if (pdfSliderChromeVisible) { - val doc = pdfDocument - if (doc != null && totalPages > 0) { - Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage") - startPageThumbnail = renderPageToBitmap(doc, sliderStartPage) - Timber.d( - "Thumbnail rendering complete. Is bitmap null: ${startPageThumbnail == null}" - ) - } - } else { - Timber.d("Slider hidden. Clearing thumbnail.") - } - } - LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) { val currentText = ttsState.currentText val currentTtsData = ttsPageData @@ -5084,8 +5173,14 @@ fun PdfViewerScreen( val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() - allAnnotations = + val nextAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) + allAnnotations = nextAnnotations + persistInkAnnotationsNow( + nextAnnotations, + emptyList(), + "draw_end" + ) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation @@ -5099,6 +5194,11 @@ fun PdfViewerScreen( erasedAnnotationsFromStroke.mapValues { it.value.toList() } + persistInkAnnotationsNow( + allAnnotations, + removalMap.values.flatten(), + "erase_end" + ) undoStack.add( HistoryAction.Remove(removalMap) ) @@ -5559,8 +5659,14 @@ fun PdfViewerScreen( val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() - allAnnotations = + val nextAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) + allAnnotations = nextAnnotations + persistInkAnnotationsNow( + nextAnnotations, + emptyList(), + "draw_end" + ) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation @@ -5574,6 +5680,11 @@ fun PdfViewerScreen( erasedAnnotationsFromStroke.mapValues { it.value.toList() } + persistInkAnnotationsNow( + allAnnotations, + removalMap.values.flatten(), + "erase_end" + ) undoStack.add( HistoryAction.Remove(removalMap) ) @@ -5916,6 +6027,42 @@ fun PdfViewerScreen( pageText = pdfSliderPageText, themePrimary = MaterialTheme.colorScheme.primary ) + val pdfSliderMaxPage = (totalDisplayPages - 1).coerceAtLeast(0) + val pdfSliderCurrentPage = sliderCurrentPage.roundToInt().coerceIn(0, pdfSliderMaxPage) + + suspend fun scrollPdfSliderToPage(pageIndex: Int) { + val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage) + if (displayMode == DisplayMode.PAGINATION) { + scrollPaginationToDisplayPage(targetPage) + } else { + verticalReaderState.scrollToPage(targetPage) + } + } + + fun jumpPdfSliderToPage(pageIndex: Int) { + val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage) + scrubDebounceJob.value?.cancel() + sliderCurrentPage = targetPage.toFloat() + isFastScrubbing = false + coroutineScope.launch { + scrollPdfSliderToPage(targetPage) + } + } + + fun scrubPdfSliderToPage(newValue: Float) { + sliderCurrentPage = newValue.coerceIn(0f, pdfSliderMaxPage.toFloat()) + isFastScrubbing = true + scrubDebounceJob.value?.cancel() + scrubDebounceJob.value = coroutineScope.launch { + delay(200) + if (isActive) { + val targetPage = newValue.roundToInt().coerceIn(0, pdfSliderMaxPage) + scrollPdfSliderToPage(targetPage) + sliderCurrentPage = targetPage.toFloat() + isFastScrubbing = false + } + } + } // --- Slider UI attached to the bottom chrome --- AnimatedVisibility( @@ -5926,144 +6073,79 @@ fun PdfViewerScreen( .align(Alignment.BottomCenter) .padding(bottom = pdfSliderBottomPadding) ) { - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(Modifier.height(72.dp)) - Box( + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) {} + ) { + Row( modifier = Modifier .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) {} + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - BoxWithConstraints( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Slider( - value = sliderCurrentPage, - onValueChange = { newValue -> - sliderCurrentPage = newValue - isFastScrubbing = true - scrubDebounceJob.value?.cancel() - scrubDebounceJob.value = coroutineScope.launch { - delay(200) - if (isActive) { - val targetPage = newValue.roundToInt() - if (displayMode == DisplayMode.PAGINATION) { - scrollPaginationToDisplayPage(targetPage) - } else { - verticalReaderState.scrollToPage(targetPage) - } - isFastScrubbing = false - } - } - }, - valueRange = 0f..(totalDisplayPages - 1).toFloat().coerceAtLeast(0f), - steps = if (totalDisplayPages > 2) totalDisplayPages - 2 else 0, - modifier = Modifier.fillMaxWidth(), - thumb = { - Surface( - modifier = Modifier.size(20.dp), - shape = CircleShape, - color = pdfReaderSliderColors.thumbColor, - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) {} - }, - track = { sliderState -> - val trackHeight = 2.dp - val trackShape = RoundedCornerShape(trackHeight) - val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start - val fraction = if (range == 0f) 0f else { - ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .height(trackHeight) - .background( - color = pdfReaderSliderColors.inactiveTrackColor, - shape = trackShape - ) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .background( - color = pdfReaderSliderColors.activeTrackColor, - shape = trackShape - ) - ) - } - } - ) - - val startPageOffsetFraction = if (totalDisplayPages > 1) { - sliderStartPage.toFloat() / (totalDisplayPages - 1) - } else { - 0f - } - val thumbWidth = 20.dp - val trackWidth = maxWidth - thumbWidth - val startPagePixelPosition = - (trackWidth * startPageOffsetFraction) + (thumbWidth / 2) - - val indicatorSize = 8.dp - val indicatorOffset = startPagePixelPosition - (indicatorSize / 2) - Surface( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = indicatorOffset) - .size(indicatorSize), - shape = CircleShape, - color = pdfReaderSliderColors.bookmarkColor - ) {} - - startPageThumbnail?.let { thumbnail -> - ThumbnailWithIndicator( - thumbnail = thumbnail, - borderColor = pdfReaderSliderColors.bookmarkColor, - modifier = Modifier - .graphicsLayer { clip = false } - .align(Alignment.TopStart) - .offset( - x = startPagePixelPosition - (45.dp / 2), - y = (-72).dp - ), - onClick = { - sliderCurrentPage = sliderStartPage.toFloat() - coroutineScope.launch { - if (displayMode == DisplayMode.PAGINATION) { - scrollPaginationToDisplayPage(sliderStartPage) - } else { - verticalReaderState.scrollToPage(sliderStartPage) - } - } - } + IconButton( + onClick = { + jumpPdfSliderToPage( + readerSliderStepPage( + currentPage = pdfSliderCurrentPage, + delta = -1, + minPage = 0, + maxPage = pdfSliderMaxPage ) - } - } + ) + }, + enabled = pdfSliderCurrentPage > 0, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = stringResource(R.string.desktop_previous_page), + tint = pdfReaderSliderColors.contentColor.copy( + alpha = if (pdfSliderCurrentPage > 0) 0.9f else 0.32f + ) + ) + } - Text( - text = pdfPageRangeText( - pageIndex = sliderCurrentPage.roundToInt(), - pageCount = totalDisplayPages, - displayMode = displayMode, - settings = pdfSpreadSettings - ), - style = MaterialTheme.typography.bodyLarge, - color = pdfReaderSliderColors.contentColor, - fontSize = 18.sp + ReaderMinimalSlider( + value = sliderCurrentPage.coerceIn(0f, pdfSliderMaxPage.toFloat()), + onValueChange = ::scrubPdfSliderToPage, + valueRange = 0f..pdfSliderMaxPage.toFloat(), + enabled = pdfSliderMaxPage > 0, + activeColor = pdfReaderSliderColors.activeTrackColor, + inactiveColor = pdfReaderSliderColors.inactiveTrackColor, + thumbColor = pdfReaderSliderColors.thumbColor, + markerValue = sliderStartPage.toFloat(), + markerColor = pdfReaderSliderColors.bookmarkColor, + modifier = Modifier + .weight(1f) + .height(32.dp) + ) + + IconButton( + onClick = { + jumpPdfSliderToPage( + readerSliderStepPage( + currentPage = pdfSliderCurrentPage, + delta = 1, + minPage = 0, + maxPage = pdfSliderMaxPage + ) + ) + }, + enabled = pdfSliderCurrentPage < pdfSliderMaxPage, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = stringResource(R.string.desktop_next_page), + tint = pdfReaderSliderColors.contentColor.copy( + alpha = if (pdfSliderCurrentPage < pdfSliderMaxPage) 0.9f else 0.32f + ) ) } } @@ -7199,7 +7281,7 @@ fun PdfViewerScreen( ) val ttsAlignmentBias by animateFloatAsState( - targetValue = if (isTtsCollapsed) 1f else 0f, + targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize), label = "TtsAlignAnimation" ) @@ -7216,8 +7298,11 @@ fun PdfViewerScreen( ttsController = ttsController, ttsState = ttsState, currentTtsMode = currentTtsMode, - isCollapsed = isTtsCollapsed, - onCollapseChange = { isTtsCollapsed = it }, + overlaySize = ttsOverlaySize, + onOverlaySizeChange = { newSize -> + ttsOverlaySize = newSize + saveReaderTtsOverlaySize(context, newSize) + }, onLocateCurrentChunk = { ttsDisplayPageIndex?.let { targetPage -> coroutineScope.launch { 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 9205052..f4aef13 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt @@ -68,6 +68,17 @@ private const val ZWSP = "\u200B" internal fun String.hasRenderableRichText(): Boolean = any { it != PAGE_BREAK_CHAR && !it.isWhitespace() } +internal fun androidPdfRichTextSelectionBounds( + selectionStart: Int, + selectionEnd: Int, + textLength: Int +): Pair? { + val safeLength = textLength.coerceAtLeast(0) + val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + return if (localStart < localEnd) localStart to localEnd else null +} + object PdfFontCache { private val cache = ConcurrentHashMap() private var assetManager: android.content.res.AssetManager? = null 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 1a7e6b7..7bfb240 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -12,6 +12,7 @@ import android.graphics.Rect import android.graphics.RectF import android.net.Uri import android.os.Build +import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES import com.aryan.reader.FileType import com.aryan.reader.R import com.aryan.reader.pptx.PptxDocumentWrapper @@ -106,7 +107,7 @@ object DocumentFactory { throw e } PptxDocumentWrapper(cacheFile, deleteOnClose = true) - } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + } else if (type in COMIC_ARCHIVE_FILE_TYPES) { val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}") withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { input -> @@ -534,7 +535,7 @@ class PdfTextPageWrapper( } } -// ================= CBZ, CBR, CB7 IMPLEMENTATION ================= +// ================= CBZ, CBR, CB7, CBT IMPLEMENTATION ================= class DummyTextPage : ReaderTextPage { override suspend fun textPageCountChars() = 0 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 e1bc860..4d1a1a9 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 com.aryan.reader.shared.pdf.SharedPdfAnnotationComment import org.json.JSONArray import org.json.JSONObject +import timber.log.Timber import java.util.Locale import java.util.UUID @@ -143,7 +144,7 @@ object AnnotationSerializer { resultMap[pageIndex]?.add(annotation) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF ink annotations") } return resultMap } @@ -217,7 +218,7 @@ object TextBoxSerializer { ) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF text boxes") } return result } @@ -290,7 +291,7 @@ object HighlightSerializer { ) } } catch (e: Exception) { - e.printStackTrace() + Timber.e(e, "Failed to parse PDF highlights") } return result } 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 73ea063..6e1f59f 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 @@ -20,6 +20,8 @@ package com.aryan.reader.pdf.data import android.content.Context +import com.aryan.reader.logCloudAnnotationSyncTrace +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -34,6 +36,13 @@ class PdfAnnotationRepository(private val context: Context) { return File(dir, "annotation_$safeBookId.json") } + private fun getDeletedFile(bookId: String): File { + val safeBookId = bookId.replace("/", "_") + val dir = File(context.filesDir, "annotations") + if (!dir.exists()) dir.mkdirs() + return File(dir, "deleted_annotation_$safeBookId.json") + } + suspend fun saveAnnotations(bookId: String, annotations: Map>) { withContext(Dispatchers.IO) { try { @@ -47,7 +56,19 @@ class PdfAnnotationRepository(private val context: Context) { val json = AnnotationSerializer.toJson(annotations) val file = getFile(bookId) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_ink_noop book=$bookId count=${annotations.values.sumOf { it.size }} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + Timber.tag("AnnotationSync").d("Skipping unchanged annotation JSON for $bookId.") + return@withContext + } file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_ink book=$bookId count=${annotations.values.sumOf { it.size }} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}") } catch (e: Exception) { @@ -83,4 +104,63 @@ class PdfAnnotationRepository(private val context: Context) { return if (valid) file else null } + + suspend fun markAnnotationsDeleted( + bookId: String, + annotationIds: Collection, + deletedAt: Long = System.currentTimeMillis() + ) { + val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() + if (ids.isEmpty()) return + withContext(Dispatchers.IO) { + val file = getDeletedFile(bookId) + val existing = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + val next = existing.toMutableMap() + ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, deletedAt) } + val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) + if (file.isFile && file.readText() == json) return@withContext + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.mark_deleted_ink book=$bookId ids=${ids.sorted()} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + } + + suspend fun replaceDeletedAnnotations( + bookId: String, + deletions: Map, + timestamp: Long? = null + ) { + withContext(Dispatchers.IO) { + val file = getDeletedFile(bookId) + if (deletions.isEmpty()) { + if (file.exists()) file.delete() + return@withContext + } + val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions) + if (!file.isFile || file.readText() != json) { + file.writeText(json) + } + timestamp?.takeIf { it > 0L }?.let(file::setLastModified) + logCloudAnnotationSyncTrace { + "android.repository.replace_deleted_ink book=$bookId count=${deletions.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + } + + fun getDeletedAnnotationsFileForSync(bookId: String): File? { + val file = getDeletedFile(bookId) + val deletions = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + return if (deletions.isNotEmpty()) file else null + } } diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt index 07cfe77..b19b252 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt @@ -2,6 +2,7 @@ package com.aryan.reader.pdf.data import android.content.Context +import com.aryan.reader.logCloudAnnotationSyncTrace import com.aryan.reader.pdf.PdfUserHighlight import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -25,7 +26,19 @@ class PdfHighlightRepository(private val context: Context) { if (file.exists()) file.delete() return@withContext } - file.writeText(HighlightSerializer.toJson(highlights)) + val json = HighlightSerializer.toJson(highlights) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_highlights_noop book=$bookId count=${highlights.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + return@withContext + } + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_highlights book=$bookId count=${highlights.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } } catch (e: Exception) { Timber.e(e, "Failed to save local highlights") } @@ -52,4 +65,4 @@ class PdfHighlightRepository(private val context: Context) { val dir = File(context.filesDir, "pdf_highlights") if (dir.exists()) dir.deleteRecursively() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt index 10dea67..294c0a1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextBoxRepository.kt @@ -20,6 +20,7 @@ package com.aryan.reader.pdf.data import android.content.Context +import com.aryan.reader.logCloudAnnotationSyncTrace import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -35,13 +36,24 @@ class PdfTextBoxRepository(private val context: Context) { suspend fun saveTextBoxes(bookId: String, textBoxes: List) { withContext(Dispatchers.IO) { + val file = getFile(bookId) if (textBoxes.isEmpty()) { - val file = getFile(bookId) if (file.exists()) file.delete() return@withContext } val json = TextBoxSerializer.toJson(textBoxes) - getFile(bookId).writeText(json) + if (file.exists() && file.readText() == json) { + logCloudAnnotationSyncTrace { + "android.repository.save_textboxes_noop book=$bookId count=${textBoxes.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + return@withContext + } + file.writeText(json) + logCloudAnnotationSyncTrace { + "android.repository.save_textboxes book=$bookId count=${textBoxes.size} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } } } @@ -71,4 +83,4 @@ class PdfTextBoxRepository(private val context: Context) { val file = getFile(bookId) if(file.exists()) file.delete() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index d1f0136..8cadc36 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -81,6 +81,13 @@ internal fun resolveNativeTtsVoiceForBuild( } } +internal fun shouldResolveNativeTtsVoice( + preferredVoiceName: String?, + isOfflineBuild: Boolean +): Boolean { + return isOfflineBuild || !preferredVoiceName.isNullOrBlank() +} + class BaseTtsSynthesizer(private val context: Context) { private var tts: TextToSpeech? = null @@ -144,15 +151,6 @@ class BaseTtsSynthesizer(private val context: Context) { if (status == TextToSpeech.SUCCESS) { isInitialized = true Timber.d("TextToSpeech engine initialized successfully.") - try { - val result = tts?.setLanguage(Locale.getDefault()) - if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { - Timber.e("Default language not supported/missing data") - } - } catch (e: Exception) { - Timber.e(e, "Error setting language") - } - tts?.setOnUtteranceProgressListener(sharedListener) if (continuation.isActive) continuation.resume(Unit) } else { @@ -183,6 +181,9 @@ class BaseTtsSynthesizer(private val context: Context) { try { val preferredVoiceName = loadNativeVoice(context) + if (!shouldResolveNativeTtsVoice(preferredVoiceName, BuildConfig.IS_OFFLINE)) { + return + } val defaultLocale = Locale.getDefault() val defaultVoice = tts?.defaultVoice val availableVoices = tts?.voices @@ -195,7 +196,6 @@ class BaseTtsSynthesizer(private val context: Context) { ) if (targetVoice == null) { - tts?.language = defaultLocale Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.") return } @@ -208,15 +208,10 @@ class BaseTtsSynthesizer(private val context: Context) { Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.") } - if (tts?.voice?.name != targetVoice.name) { - Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})") - try { - tts?.language = targetVoice.locale - } catch (e: Exception) { - Timber.e(e, "BaseTts: Failed to set language for voice") - } - tts?.voice = targetVoice - } + Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})") + tts?.voice = targetVoice + } catch (e: OutOfMemoryError) { + Timber.e(e, "BaseTts: Skipping optional voice selection due to low memory") } catch (e: Exception) { Timber.e(e, "BaseTts: Failed to apply preferred voice") } diff --git a/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt b/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt index 4d522bf..aac10c4 100644 --- a/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt +++ b/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt @@ -74,20 +74,16 @@ fun ReaderTtsMiniBar( ttsState.currentChunkIndex >= 0 && ttsState.currentChunkIndex < ttsState.totalChunks - 1 val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) { - if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) { - "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}" - } else { - null - } + formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks) } val title = ttsState.bookTitle ?.takeIf { it.isNotBlank() } ?: stringResource(R.string.action_read_aloud) val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) { listOfNotNull( + chunkLabel, ttsState.chapterTitle - ?.takeIf { it.isNotBlank() && it != title }, - chunkLabel + ?.takeIf { it.isNotBlank() && it != title } ).joinToString(" - ") } diff --git a/app/src/main/java/com/aryan/reader/tts/ReaderTtsOverlaySize.kt b/app/src/main/java/com/aryan/reader/tts/ReaderTtsOverlaySize.kt new file mode 100644 index 0000000..0a509cc --- /dev/null +++ b/app/src/main/java/com/aryan/reader/tts/ReaderTtsOverlaySize.kt @@ -0,0 +1,42 @@ +package com.aryan.reader.tts + +import android.content.Context +import androidx.core.content.edit + +enum class ReaderTtsOverlaySize { + LARGE, + MEDIUM, + SMALL +} + +private const val READER_PREFS_NAME = "reader_prefs" +private const val READER_TTS_OVERLAY_SIZE_KEY = "reader_tts_overlay_size" + +internal fun readerTtsOverlayAlignmentBias(size: ReaderTtsOverlaySize): Float { + return if (size == ReaderTtsOverlaySize.SMALL) 1f else 0f +} + +internal fun readerTtsOverlayAlternativeSizes(size: ReaderTtsOverlaySize): List { + return ReaderTtsOverlaySize.entries.filter { it != size } +} + +internal fun resolveReaderTtsOverlaySize(savedName: String?): ReaderTtsOverlaySize { + return ReaderTtsOverlaySize.entries.firstOrNull { it.name == savedName } + ?: ReaderTtsOverlaySize.LARGE +} + +internal fun loadReaderTtsOverlaySize(context: Context): ReaderTtsOverlaySize { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + return resolveReaderTtsOverlaySize(prefs.getString(READER_TTS_OVERLAY_SIZE_KEY, null)) +} + +internal fun saveReaderTtsOverlaySize(context: Context, size: ReaderTtsOverlaySize) { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putString(READER_TTS_OVERLAY_SIZE_KEY, size.name) } +} + +internal fun formatReaderTtsChunkLabel(currentChunkIndex: Int, totalChunks: Int): String? { + if (totalChunks <= 0) return null + if (currentChunkIndex !in 0 until totalChunks) return null + return "Chunk ${currentChunkIndex + 1}/$totalChunks" +} 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 52231ae..0c42007 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -155,6 +155,13 @@ internal fun shouldStartTtsTransitionPrefetch( return currentGeneration != deferredGeneration } +internal fun shouldStopTtsPrefetchAfterMissingChunk( + isLoaded: Boolean, + playlistIndex: Int? +): Boolean { + return !isLoaded && playlistIndex == null +} + internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? { if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS) @@ -1592,11 +1599,21 @@ class TtsPlaybackManager( ) return@launch } - if (!loadedChunks.contains(targetIndex) && findPlaylistIndexForChunk(targetIndex) == null) { - logChunkNavWarnMain( - "prefetch-stop-after-missing-chunk", - "Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous." - ) + val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) { + val playlistIndex = findPlaylistIndexForChunk(targetIndex) + shouldStopTtsPrefetchAfterMissingChunk( + isLoaded = loadedChunks.contains(targetIndex), + playlistIndex = playlistIndex + ).also { shouldStop -> + if (shouldStop) { + logChunkNavWarnMain( + "prefetch-stop-after-missing-chunk", + "Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous." + ) + } + } + } + if (shouldStopAfterMissingChunk) { return@launch } } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt index cb403f3..19a9737 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt @@ -113,7 +113,31 @@ fun formatBytes(bytes: Long): String { } class TtsCacheManager(private val context: Context) { - private fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + private val baseDir: File + get() = File(context.filesDir, "TTS_Cache") + + private fun safeCacheSegment(name: String, fallback: String): String { + val normalized = name.trim().takeIf { it.isNotBlank() } ?: fallback + val slug = normalized + .replace(Regex("[^a-zA-Z0-9_-]+"), "_") + .trim('_', '-') + .ifBlank { fallback } + .take(48) + return "${slug}_${hash(normalized).take(16)}" + } + + private fun sanitizeFileToken(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9._-]+"), "_") + .trim('.', '_', '-') + .ifBlank { "default" } + } + + private fun bookDirName(bookTitle: String): String = safeCacheSegment(bookTitle, "book") + + private fun chapterDirName(chapterTitle: String?): String { + return safeCacheSegment(chapterTitle ?: "Unknown_Chapter", "chapter") + } private fun hash(input: String): String { val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) @@ -121,9 +145,8 @@ class TtsCacheManager(private val context: Context) { } fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) { - val baseDir = File(context.filesDir, "TTS_Cache") - val bookDir = File(baseDir, sanitize(bookTitle.take(50))) - val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + val bookDir = getBookCacheDir(bookTitle) + val chapterDir = File(bookDir, chapterDirName(chapterTitle)) if (!chapterDir.exists()) chapterDir.mkdirs() val metaFile = File(chapterDir, "total_chunks.txt") metaFile.writeText(totalChunks.toString()) @@ -137,22 +160,20 @@ class TtsCacheManager(private val context: Context) { speakerId: String, mode: TtsPlaybackManager.TtsMode ): File { - val baseDir = File(context.filesDir, "TTS_Cache") - val bookDir = File(baseDir, sanitize(bookTitle.take(50))) - val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + val bookDir = getBookCacheDir(bookTitle) + val chapterDir = File(bookDir, chapterDirName(chapterTitle)) if (!chapterDir.exists()) { chapterDir.mkdirs() } val hashParams = hash(text + speakerId + mode.name) - val safeSpeaker = sanitize(speakerId) + val safeSpeaker = sanitizeFileToken(speakerId) return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav") } fun getBookCacheDir(bookTitle: String): File { - val baseDir = File(context.filesDir, "TTS_Cache") - return File(baseDir, sanitize(bookTitle.take(50))) + return File(baseDir, bookDirName(bookTitle)) } fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List { @@ -194,18 +215,35 @@ class TtsCacheManager(private val context: Context) { } fun deleteChapterCache(chapterDir: File) { - chapterDir.deleteRecursively() + if (chapterDir.isInsideBaseDir()) { + chapterDir.deleteRecursively() + } } fun deleteSpecificFiles(files: List, chapterDir: File) { - files.forEach { it.delete() } - if (chapterDir.listFiles()?.isEmpty() == true) { + if (!chapterDir.isInsideBaseDir()) return + files.forEach { file -> + if (file.isInside(chapterDir)) { + file.delete() + } + } + if (chapterDir.listFiles()?.isEmpty() == true && chapterDir.isInsideBaseDir()) { chapterDir.deleteRecursively() } } fun clearBookCache(bookTitle: String) { - getBookCacheDir(bookTitle).deleteRecursively() + getBookCacheDir(bookTitle).takeIf { it.isInsideBaseDir() }?.deleteRecursively() + } + + private fun File.isInsideBaseDir(): Boolean { + return isInside(baseDir) + } + + private fun File.isInside(root: File): Boolean { + val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false + val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false + return targetPath != rootPath && targetPath.startsWith(rootPath + File.separator) } } diff --git a/app/src/main/res/drawable-nodpi/account_circle.xml b/app/src/main/res/drawable-nodpi/account_circle.xml new file mode 100644 index 0000000..9cca17a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/account_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/add.xml b/app/src/main/res/drawable-nodpi/add.xml new file mode 100644 index 0000000..d6fd3d3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_back.xml b/app/src/main/res/drawable-nodpi/arrow_back.xml new file mode 100644 index 0000000..0e2e863 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_downward.xml b/app/src/main/res/drawable-nodpi/arrow_downward.xml new file mode 100644 index 0000000..e383ebd --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_downward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_drop_down.xml b/app/src/main/res/drawable-nodpi/arrow_drop_down.xml new file mode 100644 index 0000000..dfea22c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_drop_up.xml b/app/src/main/res/drawable-nodpi/arrow_drop_up.xml new file mode 100644 index 0000000..05735c6 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_drop_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_forward.xml b/app/src/main/res/drawable-nodpi/arrow_forward.xml new file mode 100644 index 0000000..81139b1 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_forward.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/arrow_upward.xml b/app/src/main/res/drawable-nodpi/arrow_upward.xml new file mode 100644 index 0000000..8e4a2bc --- /dev/null +++ b/app/src/main/res/drawable-nodpi/arrow_upward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/book.xml b/app/src/main/res/drawable-nodpi/book.xml new file mode 100644 index 0000000..ec1fb1d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/book.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/bookmark_border.xml b/app/src/main/res/drawable-nodpi/bookmark_border.xml new file mode 100644 index 0000000..cc78582 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/bookmark_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/brush.xml b/app/src/main/res/drawable-nodpi/brush.xml new file mode 100644 index 0000000..9f7fdcb --- /dev/null +++ b/app/src/main/res/drawable-nodpi/brush.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/bug_report.xml b/app/src/main/res/drawable-nodpi/bug_report.xml new file mode 100644 index 0000000..108a234 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/bug_report.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/check.xml b/app/src/main/res/drawable-nodpi/check.xml new file mode 100644 index 0000000..280f0bd --- /dev/null +++ b/app/src/main/res/drawable-nodpi/check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/chevron_left.xml b/app/src/main/res/drawable-nodpi/chevron_left.xml new file mode 100644 index 0000000..7c486f8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/chevron_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/chevron_right.xml b/app/src/main/res/drawable-nodpi/chevron_right.xml new file mode 100644 index 0000000..3d036ec --- /dev/null +++ b/app/src/main/res/drawable-nodpi/chevron_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/cloud.xml b/app/src/main/res/drawable-nodpi/cloud.xml new file mode 100644 index 0000000..b665132 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/cloud.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/cloud_download.xml b/app/src/main/res/drawable-nodpi/cloud_download.xml new file mode 100644 index 0000000..41ebcc0 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/cloud_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/code.xml b/app/src/main/res/drawable-nodpi/code.xml new file mode 100644 index 0000000..8e7fc92 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/code.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/collapse_all.xml b/app/src/main/res/drawable-nodpi/collapse_all.xml new file mode 100644 index 0000000..6d30f6b --- /dev/null +++ b/app/src/main/res/drawable-nodpi/collapse_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/content_copy.xml b/app/src/main/res/drawable-nodpi/content_copy.xml new file mode 100644 index 0000000..c744e1a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/content_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/copy_all.xml b/app/src/main/res/drawable-nodpi/copy_all.xml new file mode 100644 index 0000000..0cf3a90 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/copy_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/create_new_folder.xml b/app/src/main/res/drawable-nodpi/create_new_folder.xml new file mode 100644 index 0000000..8767320 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/create_new_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/delete.xml b/app/src/main/res/drawable-nodpi/delete.xml new file mode 100644 index 0000000..d724c2e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/description.xml b/app/src/main/res/drawable-nodpi/description.xml new file mode 100644 index 0000000..9e37e87 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/description.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/devices.xml b/app/src/main/res/drawable-nodpi/devices.xml new file mode 100644 index 0000000..7d69f1a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/devices.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/do_not_touch.xml b/app/src/main/res/drawable-nodpi/do_not_touch.xml new file mode 100644 index 0000000..40e3521 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/do_not_touch.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/download.xml b/app/src/main/res/drawable-nodpi/download.xml new file mode 100644 index 0000000..dba4601 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/edit.xml b/app/src/main/res/drawable-nodpi/edit.xml new file mode 100644 index 0000000..b253108 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_all.xml b/app/src/main/res/drawable-nodpi/expand_all.xml new file mode 100644 index 0000000..f9ab87a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_less.xml b/app/src/main/res/drawable-nodpi/expand_less.xml new file mode 100644 index 0000000..c194db7 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_less.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/expand_more.xml b/app/src/main/res/drawable-nodpi/expand_more.xml new file mode 100644 index 0000000..0c79f6e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/expand_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/favorite.xml b/app/src/main/res/drawable-nodpi/favorite.xml new file mode 100644 index 0000000..2e40a45 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/favorite_border.xml b/app/src/main/res/drawable-nodpi/favorite_border.xml new file mode 100644 index 0000000..2e40a45 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/favorite_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/file_open.xml b/app/src/main/res/drawable-nodpi/file_open.xml new file mode 100644 index 0000000..c94ebed --- /dev/null +++ b/app/src/main/res/drawable-nodpi/file_open.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/filter_list.xml b/app/src/main/res/drawable-nodpi/filter_list.xml new file mode 100644 index 0000000..3a6f319 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/filter_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/folder.xml b/app/src/main/res/drawable-nodpi/folder.xml new file mode 100644 index 0000000..fc4e96c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/folder_special.xml b/app/src/main/res/drawable-nodpi/folder_special.xml new file mode 100644 index 0000000..273a817 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/folder_special.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/format_list_numbered.xml b/app/src/main/res/drawable-nodpi/format_list_numbered.xml new file mode 100644 index 0000000..03106f4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/format_list_numbered.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/fullscreen.xml b/app/src/main/res/drawable-nodpi/fullscreen.xml new file mode 100644 index 0000000..16f704f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/fullscreen.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/fullscreen_exit.xml b/app/src/main/res/drawable-nodpi/fullscreen_exit.xml new file mode 100644 index 0000000..54177a1 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/fullscreen_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/gavel.xml b/app/src/main/res/drawable-nodpi/gavel.xml new file mode 100644 index 0000000..9c3d180 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/gavel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/graphic_eq.xml b/app/src/main/res/drawable-nodpi/graphic_eq.xml new file mode 100644 index 0000000..ee498e8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/graphic_eq.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/import_export.xml b/app/src/main/res/drawable-nodpi/import_export.xml new file mode 100644 index 0000000..9f16b52 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/import_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/info.xml b/app/src/main/res/drawable-nodpi/info.xml new file mode 100644 index 0000000..7eda45e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keep.xml b/app/src/main/res/drawable-nodpi/keep.xml new file mode 100644 index 0000000..b7be67f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keep.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml new file mode 100644 index 0000000..3f4697d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml new file mode 100644 index 0000000..7c486f8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml new file mode 100644 index 0000000..3d036ec --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml b/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml new file mode 100644 index 0000000..62e0593 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/keyboard_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/list.xml b/app/src/main/res/drawable-nodpi/list.xml new file mode 100644 index 0000000..a16c937 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/list.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/lock.xml b/app/src/main/res/drawable-nodpi/lock.xml new file mode 100644 index 0000000..67e9183 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/lock.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/lock_open.xml b/app/src/main/res/drawable-nodpi/lock_open.xml new file mode 100644 index 0000000..111e0fb --- /dev/null +++ b/app/src/main/res/drawable-nodpi/lock_open.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/mail.xml b/app/src/main/res/drawable-nodpi/mail.xml new file mode 100644 index 0000000..a50fe01 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/mail.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/menu.xml b/app/src/main/res/drawable-nodpi/menu.xml new file mode 100644 index 0000000..538d1cf --- /dev/null +++ b/app/src/main/res/drawable-nodpi/menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/menu_book.xml b/app/src/main/res/drawable-nodpi/menu_book.xml new file mode 100644 index 0000000..bca9124 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/menu_book.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/more_vert.xml b/app/src/main/res/drawable-nodpi/more_vert.xml new file mode 100644 index 0000000..e4aa85d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/more_vert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/my_location.xml b/app/src/main/res/drawable-nodpi/my_location.xml new file mode 100644 index 0000000..d023089 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/my_location.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/navigate_before.xml b/app/src/main/res/drawable-nodpi/navigate_before.xml new file mode 100644 index 0000000..dd65a77 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/navigate_before.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/navigate_next.xml b/app/src/main/res/drawable-nodpi/navigate_next.xml new file mode 100644 index 0000000..7a1867c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/open_in_new.xml b/app/src/main/res/drawable-nodpi/open_in_new.xml new file mode 100644 index 0000000..d7dabf4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/open_in_new.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/phone_android.xml b/app/src/main/res/drawable-nodpi/phone_android.xml new file mode 100644 index 0000000..cdfa780 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/phone_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/play_arrow.xml b/app/src/main/res/drawable-nodpi/play_arrow.xml new file mode 100644 index 0000000..9bc6b5d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/play_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/policy.xml b/app/src/main/res/drawable-nodpi/policy.xml new file mode 100644 index 0000000..646b819 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/policy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/psychology.xml b/app/src/main/res/drawable-nodpi/psychology.xml new file mode 100644 index 0000000..e7835ad --- /dev/null +++ b/app/src/main/res/drawable-nodpi/psychology.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/push_pin.xml b/app/src/main/res/drawable-nodpi/push_pin.xml new file mode 100644 index 0000000..b7be67f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/push_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/redo.xml b/app/src/main/res/drawable-nodpi/redo.xml new file mode 100644 index 0000000..d47efe3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/redo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/refresh.xml b/app/src/main/res/drawable-nodpi/refresh.xml new file mode 100644 index 0000000..f4302a5 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/remove.xml b/app/src/main/res/drawable-nodpi/remove.xml new file mode 100644 index 0000000..46c12d3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/remove.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/restore.xml b/app/src/main/res/drawable-nodpi/restore.xml new file mode 100644 index 0000000..93ca20d --- /dev/null +++ b/app/src/main/res/drawable-nodpi/restore.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/save.xml b/app/src/main/res/drawable-nodpi/save.xml new file mode 100644 index 0000000..2da0ac2 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/save.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/screen_rotation.xml b/app/src/main/res/drawable-nodpi/screen_rotation.xml new file mode 100644 index 0000000..708aed3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/screen_rotation.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/settings.xml b/app/src/main/res/drawable-nodpi/settings.xml new file mode 100644 index 0000000..4bcd4aa --- /dev/null +++ b/app/src/main/res/drawable-nodpi/settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/settings_backup_restore.xml b/app/src/main/res/drawable-nodpi/settings_backup_restore.xml new file mode 100644 index 0000000..2c551ca --- /dev/null +++ b/app/src/main/res/drawable-nodpi/settings_backup_restore.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/share.xml b/app/src/main/res/drawable-nodpi/share.xml new file mode 100644 index 0000000..9224dbe --- /dev/null +++ b/app/src/main/res/drawable-nodpi/share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/smartphone.xml b/app/src/main/res/drawable-nodpi/smartphone.xml new file mode 100644 index 0000000..03fb0c2 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/smartphone.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/star.xml b/app/src/main/res/drawable-nodpi/star.xml new file mode 100644 index 0000000..0a592a4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/star.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/stop.xml b/app/src/main/res/drawable-nodpi/stop.xml new file mode 100644 index 0000000..cfc9094 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/swap_horiz.xml b/app/src/main/res/drawable-nodpi/swap_horiz.xml new file mode 100644 index 0000000..bd8b94b --- /dev/null +++ b/app/src/main/res/drawable-nodpi/swap_horiz.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/text_fields.xml b/app/src/main/res/drawable-nodpi/text_fields.xml new file mode 100644 index 0000000..672a80a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/text_fields.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/text_select_start.xml b/app/src/main/res/drawable-nodpi/text_select_start.xml new file mode 100644 index 0000000..2d59fa3 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/text_select_start.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/touch_app.xml b/app/src/main/res/drawable-nodpi/touch_app.xml new file mode 100644 index 0000000..6df7b49 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/touch_app.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/tune.xml b/app/src/main/res/drawable-nodpi/tune.xml new file mode 100644 index 0000000..c37eb33 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/tune.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/upload_file.xml b/app/src/main/res/drawable-nodpi/upload_file.xml new file mode 100644 index 0000000..7f7df0f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/upload_file.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/verified.xml b/app/src/main/res/drawable-nodpi/verified.xml new file mode 100644 index 0000000..5b8996e --- /dev/null +++ b/app/src/main/res/drawable-nodpi/verified.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/verified_user.xml b/app/src/main/res/drawable-nodpi/verified_user.xml new file mode 100644 index 0000000..e0bddb4 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/verified_user.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/visibility.xml b/app/src/main/res/drawable-nodpi/visibility.xml new file mode 100644 index 0000000..0fded3f --- /dev/null +++ b/app/src/main/res/drawable-nodpi/visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/visibility_off.xml b/app/src/main/res/drawable-nodpi/visibility_off.xml new file mode 100644 index 0000000..6fa698a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/volume_up.xml b/app/src/main/res/drawable-nodpi/volume_up.xml new file mode 100644 index 0000000..bc9c5c8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/volume_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 92b25a0..722414e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1086,4 +1086,430 @@ Nederlands (Tiếng Hà Lan) Українська (Tiếng Ukraina) Bahasa Indonesia (Tiếng Indonesia) + Hiển thị thẻ trên thanh ứng dụng trên cùng + Tắt đồng bộ cục bộ + Bật đồng bộ cục bộ + Đã tắt đồng bộ cục bộ + Tắt đồng bộ thư mục cục bộ? + Episteme sẽ ngừng quét thư mục này và ngừng ghi các tệp đồng bộ JSON. Cũng xóa thư mục %1$s khỏi thư mục này chứ? + Giữ dữ liệu đồng bộ + Xóa dữ liệu đồng bộ + Xóa phông chữ? + Bạn có chắc muốn xóa %1$d phông chữ đã chọn không? Phông chữ sẽ bị xóa khỏi tất cả thiết bị nếu đang bật đồng bộ. + Không có thư mục cục bộ nào bật đồng bộ. + Đã tắt đồng bộ thư mục cục bộ. + Đã tắt đồng bộ thư mục cục bộ. Đã xóa thư mục dữ liệu đồng bộ. + Đã tắt đồng bộ thư mục cục bộ, nhưng không thể xóa thư mục dữ liệu đồng bộ. + Đã bật đồng bộ thư mục cục bộ. + Dọc (WebView) + Dọc (Native Beta) + Thay thế từ cho sách + Đoạn TTS trước + Đoạn TTS tiếp theo + Hình ảnh + Không tìm thấy hình ảnh nào. + Tải hình ảnh xuống + Đã lưu %1$s + Không thể lưu hình ảnh. + Dàn trang PDF + Một trang + Hai trang + Trang đầu đứng riêng + Bắt đầu dàn trang đối diện sau trang bìa. + Độ sáng + Dùng độ sáng hệ thống + Theo cài đặt độ sáng của thiết bị. + Độ sáng tùy chỉnh + Áp dụng khi màn hình đọc đang mở. + %1$d%% + Đã tạo kệ \"%1$s\". + Đã tạo kệ thông minh \"%1$s\". + Đã đổi tên kệ thành \"%1$s\". + Đã xóa kệ \"%1$s\". + Đã cập nhật \"%1$s\". + Các tệp đó đã có trong thư viện. + %1$s - %2$s + Lưu + Lưu bình luận + Thêm bình luận + Trả lời + Thêm bình luận… + Bình luận + Đang sửa bình luận + Đang trả lời %1$s + Dùng tên tệp PDF + Độ sáng + Sách hiện tại + Thêm quy tắc + Chưa có quy tắc thay thế cho sách này. + Thay thế mới + Sửa thay thế + Với + văn bản trống + Giới thiệu + Trình đọc trên desktop + Truy cập desktop + Tài khoản + Tài khoản & tín dụng + Tổng quan tài khoản + Trung tâm AI + Dùng cho tóm tắt EPUB và tóm tắt trang PDF. + Episteme OSS + Văn bản tác giả + Bộ nhớ đệm: %1$s + Đã lưu đệm + Tóm tắt đã lưu đệm + Chọn giọng Gemini dùng để đọc thành tiếng trên đám mây. + Xóa các tệp sách desktop đã tạo và bộ nhớ đệm phân trang EPUB? Chúng sẽ được tạo lại vào lần mở sách tiếp theo. + Xóa bộ nhớ đệm giọng nói + Đóng công cụ + Đồng bộ đám mây + Cloud TTS cần Gemini + Cloud TTS cần tín dụng của tài khoản đã đăng nhập + Cloud TTS sẵn sàng + Cài đặt Cloud TTS + Cloud TTS không khả dụng + Giọng Cloud TTS + Chứa + Đang tính chi phí + Tạo tóm tắt diễn biến tới vị trí hiện tại của bạn. + Tạo kệ thông minh + Còn %1$d tín dụng + %1$s tín dụng + Phông chữ đã nhập cho trình đọc + Xóa phông chữ + Xóa %1$s? Sách dùng phông này sẽ quay về phông mặc định. + Xóa \"%1$s\"? Sách vẫn ở trong thư viện của bạn. + Xóa tóm tắt + Đã tắt + Thả tệp để nhập + Thả tệp được hỗ trợ để nhập + Liên hệ trực tiếp với chúng tôi qua email cho mọi yêu cầu khác. + Bằng + Bổ sung + Phản hồi + Trường + Đường dẫn thư mục + Từ đây + Quét toàn bộ + Miễn phí, còn %1$d + Tạo tóm tắt diễn biến + Tạo tóm tắt + Báo lỗi, yêu cầu tính năng hoặc liên hệ hỗ trợ trực tiếp. + GitHub Sponsors + Ủng hộ phát triển qua GitHub Sponsors. + Đăng nhập Google chưa được cấu hình cho bản desktop này. + Lớn hơn + Trợ giúp + Báo lỗi, yêu cầu tính năng và hỗ trợ + Ẩn + Nhập tệp + Vấn đề + Mở trình theo dõi vấn đề để báo lỗi và yêu cầu tính năng. + Nhỏ hơn + Thư viện và trình đọc + Bất kỳ + Tác vụ thư viện + Thêm + Chưa có tóm tắt đã lưu đệm cho sách này. + Nhập tệp TTF, OTF hoặc WOFF2 để dùng trong sách. + Không tìm thấy phông chữ khớp với \"%1$s\" + Chưa kết nối tài khoản Google. + Chưa có tóm tắt đã lưu đệm cho phần này. + Trình đọc desktop ngoại tuyến + Trình đọc đang mở + Đang mở %1$s + Đang mở thư viện của bạn + Toán tử + Trang + PDF được bảo vệ bằng mật khẩu + Patreon + Ủng hộ dự án trên Patreon. + Đã tạm dừng + %1$s cần mật khẩu trước khi có thể mở. + Cần mật khẩu hoặc mật khẩu không đúng. + Mật khẩu đó không mở được %1$s. Nhập mật khẩu PDF rồi thử lại. + Phần trăm + Gói + Đang chuẩn bị âm thanh + Tùy chọn + Tài khoản & tín dụng + Tài khoản & tín dụng + Tài khoản này chưa mở khóa Pro. + Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. Desktop kiểm tra cùng tài khoản đã đăng nhập và dùng các tín dụng đó cho Cloud TTS, tóm tắt, tóm tắt diễn biến và các tính năng AI trả phí khác. + Đăng nhập để kiểm tra trạng thái tài khoản của bạn trên desktop. + Tài khoản này đã mở khóa Pro. + Tiến độ + Dự án + Trình đọc + Đã tắt thẻ trình đọc + Đã bật thẻ trình đọc + Làm mới + Thả để thêm vào thư viện của bạn. + Lưu trữ khóa bảo mật không khả dụng trên hệ điều hành này. Các khóa nhập ở đây sẽ được dùng cho phiên này nhưng sẽ không được lưu lại. + Trung tâm cài đặt + Khớp với công tắc ẩn trên Android cho từ điển thông minh, tóm tắt và tóm tắt diễn biến. + Tài khoản đồng bộ, Pro và tín dụng + Đã đăng nhập + Mã nguồn + Duyệt mã nguồn dự án trên GitHub. + Dừng đọc để đổi giọng. + Ủng hộ + Ủng hộ Episteme + Đóng góp giúp trình đọc tiếp tục cải thiện trên Android và desktop. + Các cách ủng hộ phát triển Episteme + Đồng bộ thư mục + Đồng bộ metadata + Tên thẻ + Gắn thẻ sách đã chọn + Văn bản tiêu đề + Công cụ + Nhập, đồng bộ và cài đặt ứng dụng + Nhập, ví dụ PDF + Xem + Bộ nhớ đệm giọng nói + Đang chuẩn bị webview nhúng… + Đang chuẩn bị webview nhúng đi kèm %1$d%% + Webview nhúng đã được cài đặt. Khởi động lại Episteme để hoàn tất thiết lập. + Không thể khởi động webview nhúng: %1$s + Đang xử lý… + Không gian làm việc + Thêm vào kệ + Tạo kệ trước, rồi thêm sách đã chọn vào đó. + Tạo chủ đề + Hiện có: %1$s + Bạn đã nhấp vào liên kết bên ngoài. + Sửa metadata EPUB + Ít hơn + …thêm + Chưa có chủ đề tùy chỉnh + Đổi tên trong ứng dụng + Thẻ, phân tách bằng dấu phẩy + Không rõ + Định nghĩa + Chú thích + Tùy chọn chú thích + Công cụ chú thích + Hỗ trợ + Chọn PDF cần lưu. + Xóa lịch sử nhảy trang + Cloud TTS thất bại. + Thêm khóa Gemini và chọn Gemini Cloud TTS trong khóa và mô hình AI. + Cloud TTS chưa được cấu hình cho bản desktop này. + Đăng nhập bằng Google để dùng Cloud TTS. + Cloud TTS cần tài khoản đã đăng nhập có tín dụng. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Màu + Tùy chọn bình luận + Tùy chỉnh + Thao tác này sẽ xóa chú thích khỏi PDF này. + Xóa chú thích? + Văn bản tài liệu + Bình luận PDF nhúng + Không thể hiển thị trang. + Tính năng không khả dụng + Đã hoàn tất + Bút máy + Ẩn kết quả tìm kiếm + Màu tô sáng %1$d + Bảng màu bút tô sáng + Tương tác + Đang lập chỉ mục %1$d/%2$d trang + Đánh dấu + %1$d kết quả + %1$d kết quả đến lúc này + Trang tiếp theo + Kết quả tìm kiếm tiếp theo + Chưa có chú thích + Chưa có dấu trang + Không có bình luận + Không có kết quả + Chưa có kết quả trong các trang đã lập chỉ mục + Không có mục lục + Không có văn bản ở đây để đọc. + Không có văn bản trên trang này để đọc. + Không có văn bản để tóm tắt. + Mở bình luận + Hết tín dụng. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng Cloud TTS cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tính năng này cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tóm tắt diễn biến cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Dùng tóm tắt cần tín dụng trên desktop. Chỉ có thể mua Pro và tín dụng từ ứng dụng Android. + Kéo trang + Tác vụ PDF thất bại + Không thể hoàn tất tác vụ PDF. + Bình luận PDF + tr. %1$d + Trang PDF %1$d + Trang %1$d - %2$s + Trang %1$s trên %2$d + Trang %1$s trên %2$d + Đã lưu PDF + Công cụ PDF + Bút chì + Đang chuẩn bị vùng chọn + Đang chuẩn bị %1$s + Trang trước + Kết quả tìm kiếm trước + Hộp thoại in đã hoàn tất. + Cần Pro + Tính năng này cần Pro. Chỉ có thể mua Pro từ ứng dụng Android, sau đó desktop sẽ dùng tài khoản đã nâng cấp sau khi đăng nhập. + Từ điển thông minh nhiều từ cần Pro. Chỉ có thể mua Pro từ ứng dụng Android, sau đó desktop sẽ dùng tài khoản đã nâng cấp sau khi đăng nhập. + Các tính năng AI trong trình đọc đang bị ẩn. + AI trên desktop chưa được cấu hình cho bản dựng này. + Áp dụng cho chế độ đọc dọc. + Bút tô sáng bo tròn + Đã lưu vào %1$s + Cuộn + Tìm trong PDF + Chọn văn bản + Đã chọn %1$s + Hiện kết quả tìm kiếm + Đăng nhập bằng Google để dùng tính năng này trên desktop. + Đăng nhập bằng Google để dùng từ điển thông minh nhiều từ trên desktop. + Đăng nhập bằng Google để dùng tóm tắt diễn biến trên desktop. + Đăng nhập bằng Google để dùng tóm tắt trên desktop. + Đã dừng + Ghi chú văn bản + ghi chú văn bản + Kiểu văn bản + Độ dày %1$s + Mục lục + Nhập để tìm trong PDF này + Chưa có tiêu đề + Xem tài khoản & tín dụng + Đã xóa bộ nhớ đệm giọng nói + Thu phóng + Phóng to + Thu nhỏ + Chọn + Tiếp tục đọc + Bỏ qua + Xuống + Lên + AI + Căn giữa + Tác giả + Quay lại thư viện + Tác vụ sách + Thư mục + Duyệt + Danh mục + Ch. %1$d + Chuyển chương + Chọn phông chữ + Chọn họa tiết trình đọc + Xóa loại tệp + Xóa chú thích trang + Xóa nguồn + Xóa trạng thái + Xóa thẻ + Đóng trình đọc + Liên tục + Bìa + Màu tùy chỉnh + Xem trước chủ đề tùy chỉnh + Giảm %1$s + Định nghĩa trang + Thao tác này sẽ xóa phần tô sáng và ghi chú của nó. + Vào toàn màn hình + Thoát toàn màn hình + Tra cứu bên ngoài + Sách + Truyện tranh + Tài liệu + Khác + Văn bản và web + Lấp đầy + Giao diện bố cục cố định + Thư mục trống + Không có tệp hoặc thư mục con được hỗ trợ ở đây. + %1$s, %2$s + %1$s - %2$s + Ẩn bộ lọc + Ẩn công cụ đọc + Nhấn một ô, rồi chọn màu. + Tiếp tục đọc và sách gần đây + Nhập sách + Nhập thư mục + Phông chữ đã nhập + %1$s %2$s + Tăng %1$s + Lịch sử nhảy trang + Bố cục và khoảng cách + Nhập tệp vào bộ nhớ ứng dụng hoặc thêm thư mục để đọc tệp tại chỗ. + Duyệt bộ sưu tập của bạn + Khóa AI + Thông minh %1$d + Chưa đọc %1$d + Đang đọc %1$d + Hoàn tất %1$d + Danh sách + Điều hướng + Chưa mở sách + Thêm thư mục để đọc tệp trực tiếp từ thư mục đó. + Chưa có thư mục + Không có mục điều hướng + Không có nội dung trang + Không tìm thấy cài đặt + Kệ thủ công và bộ sưu tập theo bộ sách sẽ xuất hiện ở đây. + Chưa có kệ + Tạo kệ thông minh để gom sách theo quy tắc. + Chưa có kệ thông minh + Thẻ đã thêm vào sách sẽ xuất hiện ở đây. + Chưa có thẻ + Chưa nhập tệp được hỗ trợ nào. + Danh mục + Xóa \"%1$s\"? Sách đọc trực tuyến từ danh mục này có thể ngừng mở nếu thông tin đăng nhập thay đổi sau này. + Không có danh mục + Thêm danh mục OPDS để duyệt sách từ xa. + Duyệt danh mục, luồng và bản tải xuống + Mở sách + Mở thư mục + Mở PDF + Màu trang và chữ + Thông tin trang + Chiều rộng trang + Các mặc định này áp dụng khi nền tảng hỗ trợ giao diện PDF dùng chung. Ghi đè PDF theo từng sách vẫn nằm trong trình đọc PDF. + Tác vụ tệp PDF + Bút tô sáng PDF + Được lưu cùng độ trong suốt tô sáng của trình đọc. + Ghim + Công cụ PDF do trình đọc quản lý + Tự động cuộn, OCR, mặc định chú thích và hiển thị công cụ chỉ dành cho PDF được quản lý bên trong trình đọc PDF đang hoạt động. + %1$s %2$s trên %3$d (%4$d%%) + Mặc định thanh công cụ trình đọc được quản lý từ trình đọc trên nền tảng này. + Công cụ đọc + Lưu hình ảnh + Tìm kiếm: %1$s + Tìm trong trình đọc + Cài đặt tìm kiếm + Vùng chọn + Tay nắm cuối vùng chọn + Tay nắm đầu vùng chọn + Thêm kệ, thẻ hoặc metadata thư mục để sắp xếp thư viện của bạn. + Bộ sưu tập, bộ sách, thẻ và thư mục + Hiện công cụ đọc + Thư mục + Thông minh + Màu trơn + Tốc độ + Bắt đầu tự động cuộn + Dừng tự động cuộn + Dừng đọc thành tiếng + Độ mạnh họa tiết + Nhập để tìm trong sách này + Kiểu chữ + Hoàn tác chú thích + Bỏ ghim + Dùng chủ đề tối + Dùng chủ đề sáng + Mono + Sans + Serif + Tìm sách, tác giả hoặc thẻ + Không có công cụ + Hiển thị + Chỉ thay thế nội dung được đọc + Văn bản trình đọc, tô sáng và vị trí vẫn không đổi. + %1$s -> %2$s diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 81381ea..a2c50fb 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -1,203 +1,204 @@ - + %1$d book %1$d books - + book books - + %1$d shelf %1$d shelves - + %1$d result found %1$d results found - + %1$d match found %1$d matches found + Delete File Permanently Delete Files Permanently - + Do you want to permanently delete %1$d selected file from your device? This action cannot be undone. Do you want to permanently delete %1$d selected files from your device? This action cannot be undone. - + Do you want to remove %1$d selected file from the recent files list? It will reappear if you open it again from the library. Do you want to remove %1$d selected files from the recent files list? It will reappear if you open it again from the library. - + Are you sure you want to remove %1$d book from the \'%2$s\' shelf? The book will remain in your library and appear under Unshelved. Are you sure you want to remove %1$d books from the \'%2$s\' shelf? The books will remain in your library and appear under Unshelved. - + %1$d book removed from library. %1$d books removed from library. - + Importing %1$d book… It will appear in your Library shortly. Importing %1$d books… They will appear in your Library shortly. - + Imported %1$d book. You can find it in the Library tab. Imported %1$d books. You can find them in the Library tab. - + %1$d book added to shelf. %1$d books added to shelf. - + %1$d book tagged with "%2$s". %1$d books tagged with "%2$s". - + Removed folder "%1$s" and %2$d book from the app. Removed folder "%1$s" and %2$d books from the app. - + %1$d folder %1$d folders - + %1$d file %1$d files - + Drop to import %1$d file Drop to import %1$d files - + %1$d unsupported file will be skipped. %1$d unsupported files will be skipped. - + Importing %1$d file… Importing %1$d files… - + Imported %1$d file. Imported %1$d files. - + Imported %1$d file. Reader support comes later. Imported %1$d files. Reader support comes later. - + Could not import %1$d file. Could not import %1$d files. - + Skipped %1$d file. Skipped %1$d files. - + Remove "%1$s" and its %2$d book from the app? Files on disk will not be deleted. Remove "%1$s" and its %2$d books from the app? Files on disk will not be deleted. - + Folder sync failed for %1$d folder. Folder sync failed for %1$d folders. - + Folder sync finished with %1$d folder skipped. Folder sync finished with %1$d folders skipped. - + Removed %1$d streamed OPDS book from that catalog. Removed %1$d streamed OPDS books from that catalog. - + %1$d tag %1$d tags - + All Books %1$d All Books %1$d - + Shelves %1$d Shelves %1$d - + Tags %1$d Tags %1$d - + Folders %1$d Folders %1$d - + (%1$d chunk) (%1$d chunks) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e56792..1d0bd06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,7 @@ Clear Apply Enable - + Error: %1$s Go Back @@ -29,12 +29,12 @@ Are you sure you want to close all active tabs? - + %1$s you agree to our %2$s and acknowledge you have read our %3$s. Terms of Service Privacy Policy Licenses - + %1$d selected Clear Selection @@ -47,10 +47,10 @@ File Information Book Name Copy Name - + Original Name: %1$s Revert to Original - + File Name: %1$s Author Format @@ -63,7 +63,7 @@ Internal storage About Episteme - + Version: %1$s (Build: %2$d) Select a File Clear All Synced Data? @@ -83,9 +83,9 @@ Sync Folder Local Folder - OPDS Stream + OPDS Pinned - + %1$d%% complete Not available locally @@ -110,7 +110,7 @@ Recent Files Limit No limit - + %1$d files Clear Book Cache Clear Reflow Cache @@ -119,17 +119,17 @@ Library Search title or author… - + Types: %1$s - + Folders: %1$d - + Status: %1$s All Books Shelves Folders Catalogs - + No results found for \"%1$s\" Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started. @@ -144,21 +144,21 @@ Add books This shelf is empty - + Add to %1$s - + ADD (%1$d) No unshelved books to add All books are already in this shelf Rename Shelf Delete Shelf? - + Are you sure you want to delete the \'%1$s\' shelf? All books will be moved to Unshelved. Remove from Shelf? - + Delete %1$s? - + Are you sure you want to delete the %1$d selected %2$s? All books within will be moved to Unshelved. @@ -176,6 +176,14 @@ BOOKS Edit Filters Remove Folder + Disable local sync + Enable local sync + Local sync disabled + Disable local folder sync? + + Episteme will stop scanning this folder and stop writing JSON sync files. Remove the %1$s folder from this folder too? + Keep sync data + Remove sync data Filter File Types Select the file types you want to sync from this folder: Filter Library @@ -230,9 +238,9 @@ Username Password Delete Catalog - + Are you sure you want to delete \'%1$s\'? - + Deleting this catalog will also permanently remove %1$d streaming books associated with it from your library. Preset @@ -298,7 +306,7 @@ Browse Google Fonts Search 1900+ fonts… Popular Choices - + No fonts found matching \'%1$s\' Already Downloaded No Custom Fonts @@ -308,8 +316,11 @@ Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;: Preview unavailable (Invalid font file) Delete Font? - + Are you sure you want to delete \'%1$s\'? This will remove it from all your devices if sync is on. + Delete Fonts? + + Are you sure you want to delete %1$d selected fonts? This will remove them from all your devices if sync is on. Get in Touch @@ -339,7 +350,7 @@ Device Limit Reached To use Episteme Pro on this device, please remove one of your existing registered devices. - + Last seen: %1$s Confirm Destructive Action @@ -427,25 +438,25 @@ [Debug] Show Device Management [Debug] Clear Cloud & Local Data - + FPS: %1$d Your device doesn\'t support folder selection. You can still import files individually. No file manager found. Please install a file manager app. Feedback: Episteme Reader - + Downloaded %1$s - + %1$s: %2$s Never - + %1$s: %2$d - + Removed %1$d streaming books. - + Failed to import font: %1$s Text view deleted. @@ -463,22 +474,22 @@ PDF saved successfully. Failed to open file for saving. - + Error saving PDF: %1$s Saving original PDF… Original PDF saved successfully. - + Sharing: %1$s Share PDF - + Share failed: %1$s - + Limit reached: Maximum %1$d folders allowed. This folder is already synced. - + Folder added: %1$s Failed to access folder permissions. Folder removed. @@ -486,8 +497,13 @@ Scanning folder for new books… Folder Sync: Scan complete. Sync failed. + No local folders have sync enabled. + Local folder sync disabled. + Local folder sync disabled. Sync data folder removed. + Local folder sync disabled, but the sync data folder could not be removed. + Local folder sync enabled. Enable sync to download files. - + Failed to download %1$s. Enable sync to clear cloud data. Not signed in, cannot clear cloud data. @@ -514,13 +530,13 @@ Failed to load generated text view. Text view generation failed. - + Failed to load FB2: %1$s - + Failed to load file: %1$s - + Failed to load MOBI: %1$s - + Failed to load EPUB: %1$s File deleted from folder. Removed from library. A shelf with that name already exists. @@ -547,7 +563,7 @@ Thinking… Open in Dictionary App AI could not provide a definition. - + Asking AI about \'%1$s\'… @@ -574,12 +590,12 @@ Loading voices… No voices available on this device. Specific Voices - + Available Voices (%1$d) No voices found for this language. - + Variant: %1$s - + This is a sample of %1$s. @@ -652,10 +668,10 @@ The book content is empty. Failed to parse summary from server response. Could not fetch summary. - + Error: %1$d. %2$s Network error. Please check connection and server status. - + Analyzing Chapter %1$d… Reading current position… Generating Recap… @@ -694,6 +710,8 @@ Delete Text View Vertical + Vertical (WebView) + Vertical (Native Beta) Paginated (left-to-right) @@ -716,6 +734,7 @@ TTS Voice Settings TTS Word Replacements + Book Word Replacements Share, Save or Print TTS Settings (Debug) @@ -765,7 +784,7 @@ Faster Decrease Increase - + Page %1$d of %2$d @@ -794,11 +813,12 @@ Are you sure you want to permanently delete this highlight? + Saved %1$s Could not save image. Original PDF not found. - + Error: Book content not found. Path: %1$s Please select a dictionary app first. Please select a translate app first. @@ -819,13 +839,13 @@ Wait for book to load fully. Release for Previous Chapter Release for Next Chapter - + Pull further… (%1$d%%) Chapter Could not get chapter content. Could not determine current chapter. WebView not available. - + Page %1$d/%2$d @@ -870,6 +890,7 @@ Follows the device brightness setting. Custom brightness Applies while a reader screen is open. + %1$d%% @@ -885,9 +906,9 @@ Open Source Version Playstore Version - + Version %1$s - + Build %1$s GitHub @@ -896,26 +917,26 @@ How we handle your data. Usage terms and conditions. Open source libraries used. - + Importing %1$d books… They will appear in your Library shortly. - + Created shelf "%1$s". - + Created smart shelf "%1$s". - + Renamed shelf to "%1$s". - + Deleted shelf "%1$s". - + Updated "%1$s". Those files are already in the library. - + %1$s - %2$s External Link - + You clicked on an external link:\n\n%1$s\n\nWhat would you like to do? Open No browser found to open the link. @@ -936,6 +957,7 @@ Note Comments Editing comment + Replying to %1$s Edit @@ -960,9 +982,9 @@ Voice Adjustments - + Speed (%1$sx) - + Pitch (%1$sx) This is how your current voice settings sound. Pause Book @@ -993,9 +1015,9 @@ Edit Note - + %1$d / %2$d - + Page %1$d of %2$d @@ -1021,13 +1043,13 @@ Insert Text Box - + OCR selection error: %1$s - + Selection error: %1$s - + Error processing page: %1$s - + Unable to display page %1$d. @@ -1043,7 +1065,7 @@ Pen Playground Import SVG - + Imported %1$d SVG strokes! Failed to import SVG or empty. @@ -1051,7 +1073,7 @@ OCR Language Insert Blank Page Delete Page - + Generating… %1$d%% Open Text View @@ -1062,17 +1084,17 @@ Print Generating Text View… - + Indexing pages… %1$d%% done. Search results will update automatically. - + Results found on %1$d+ pages - + Result %1$d / %2$d - + %1$d+ Pages - + Summarize Page (Page %1$d) - + Downloading %1$s language pack… Select OCR Language @@ -1082,7 +1104,7 @@ You can change this later in More Options > OCR Language. Re-index Document? - + You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background. Re-index @@ -1092,7 +1114,7 @@ Incorrect password Hide password Show password - + You are about to navigate to:\n%1$s Visit Save to Device @@ -1108,7 +1130,7 @@ No other PDFs found in your library. PDF is empty or could not be displayed. - + Page added at %1$d Page deleted Extra page removed @@ -1147,7 +1169,7 @@ Test Panel ML Detection Test Speech Bubble ML Detection - + Export Logs (Last %1$d lines) Enable Strict File Filter @@ -1203,7 +1225,7 @@ Close search Clear query Search shelf - + %1$s shelf cover Preserve Image Colors Keep original image colors when theme changes @@ -1228,7 +1250,7 @@ Unread In Progress Completed - + Tags: %1$s Browse by tag Tags @@ -1239,7 +1261,7 @@ Text to speech Playback controls for text to speech. Preparing text to speech - + Preparing: %1$s Active TTS Engine Cloud AI @@ -1256,7 +1278,7 @@ Offline Voice Filter No audio cached for this voice. - + Clear Cache for %1$s This is a voice sample. @@ -1265,17 +1287,17 @@ Summary Recap Cache - + No summary for %1$s yet. - + Generate Summary for %1$s Get a recap of the story up to your current position. Generate Story Recap Story Recap Cache Hit • Free - + Generated • Free (%1$d/10 left) - + Generated • Cost: %1$s credits Generating… • Cost: Calculating AI Output @@ -1284,7 +1306,7 @@ Credits AI & Cloud Credits Credits Available - + %1$d Credits Estimated Cost Breakdown Cloud TTS @@ -1302,14 +1324,15 @@ Translate + Back to Pg %1$d - + Page %1$d - + Result %1$d / %2$d Failed to load PDF. - + Downloading Bubble Zoom model… %1$d%% Exit slider navigation Jump Back @@ -1320,7 +1343,7 @@ Toggle search highlights Drag to move text box No files icon - + Copy %1$s Tag List item marker @@ -1345,7 +1368,9 @@ Document Generated + %1$s (Text View) + %1$s (Reflow) @@ -1353,16 +1378,16 @@ No tags assigned. Apply Tags Search or create tag… - + Create \"%1$s\" Pull Distance to Change Chapter Short Long - + Speed: %1$sx - + Pitch: %1$sx Play/Pause Reset Speed @@ -1375,7 +1400,7 @@ Table of Contents Bookmark - + Jump Back to Page %1$d Return to previous page Exit Smart Zoom @@ -1389,7 +1414,7 @@ Settings Edit Restore - + %1$s selected Reader defaults @@ -1402,15 +1427,15 @@ Groq AI Definition - + Chapter %1$d Location Custom Font - + %1$d. %2$s - + An error occurred: %1$s - + Error loading document: %1$s OCR found no text on this page. @@ -1422,13 +1447,13 @@ AI features are unavailable in the offline OSS build. Blocked for safety reasons. - + Choose a model for %1$s in AI key and model settings. - + Add a %1$s API key in AI key and model settings. The AI provider returned an empty response. - + AI provider error: %1$d. %2$s This summary needs a Gemini model because the selected Groq models do not support PDF/image input. @@ -1438,18 +1463,18 @@ Max 5 images allowed per message. One or more images exceed the 5MB limit. - + Failed to create ticket: %1$s - + Failed to send: %1$s - + Failed to load feed: %1$s Empty body - + Download failed: %1$s - + Download error: %1$s - + Purchase failed: %1$s Could not connect to billing service. Products not found. @@ -1460,7 +1485,7 @@ No text to read. Error starting playback. Failed to load audio. - + Playback error: %1$s Cloud TTS is not configured. @@ -1487,16 +1512,16 @@ Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini. Recaps Used for story recap generation. - + Uses the saved Gemini key. Only %1$s is supported for now. - + Save %1$s key? After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it. - + Delete %1$s key? Features using this provider will stop working until a new key is saved. No key saved - + Delete %1$s key Model No model selected @@ -1543,7 +1568,7 @@ Editable metadata Display name Name shown in Reader - + Original file: %1$s @@ -1604,6 +1629,14 @@ Plain text case-sensitive + Current book + Add rule + No replacement rules for this book yet. + New replacement + Edit replacement + With + empty text + Alice met the White Rabbit. Nederlands (Dutch) Українська (Ukrainian) Bahasa Indonesia (Indonesian) @@ -1612,10 +1645,13 @@ Desktop reader Desktop access Account + Account & credits + Account overview AI hub Used for EPUB summaries and PDF page summaries. + Episteme oss Author text - + Cache: %1$s Cached Cached summary @@ -1623,6 +1659,7 @@ Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened. Clear voice cache Close tools + Cloud sync Cloud TTS needs Gemini Cloud TTS needs signed-in credits Cloud TTS ready @@ -1633,15 +1670,15 @@ Cost calculating Create a recap up to your current position. Create smart shelf - + %1$d credits available - + %1$s credits Imported fonts for the reader Delete font - + Delete %1$s? Books using it will fall back to the default font. - + Delete \"%1$s\"? Books stay in your library. Delete summary Disabled @@ -1655,7 +1692,7 @@ Folder path From here Full scan - + Free, %1$d left Generate recap Generate summary @@ -1664,6 +1701,7 @@ Support development through GitHub Sponsors. Google sign-in is not configured for this desktop build. Greater than + Help Bug reports, feature requests, and support Hide Import files @@ -1672,14 +1710,17 @@ Less than Library and reader Any + Library actions + More No cached summaries for this book yet. Import TTF, OTF, or WOFF2 files to use them in books. - + No fonts found matching \"%1$s\" No Google account is connected. No summary cached for this section. + Offline desktop reader Open readers - + Opening %1$s Opening your library Operator @@ -1688,15 +1729,17 @@ Patreon Support the project on Patreon. Paused - + %1$s requires a password before it can be opened. Password is required or incorrect. - + That password did not open %1$s. Enter the PDF password and try again. Percent + Plan Preparing audio - Pro - Pro and credits + Preferences + Account & credits + Account & credits Pro is not unlocked for this account. Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features. Sign in to check your account status on desktop. @@ -1704,11 +1747,14 @@ Progress Project Reader + Reader tabs off + Reader tabs on Refresh Release to add to your library. Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted. Settings hub Matches the Android hide toggle for smart dictionary, summaries, and recaps. + Sync account, Pro, and credits Signed in Source code Browse the project source on GitHub. @@ -1728,10 +1774,10 @@ View Voice cache Preparing embedded webview… - + Preparing bundled embedded webview %1$d%% Embedded webview installed. Restart Episteme to finish setup. - + Embedded webview could not start: %1$s Working… Workspace @@ -1739,7 +1785,7 @@ Add to shelf Create a shelf first, then add selected books to it. Create theme - + Existing: %1$s You clicked an external link. Edit EPUB metadata @@ -1773,16 +1819,16 @@ Finished Fountain pen Hide search results - + Highlight color %1$d Highlighter palette Interaction - + Indexing %1$d/%2$d pages Markup - + %1$d matches - + %1$d matches so far Next page Next search result @@ -1805,21 +1851,21 @@ PDF action failed The PDF action could not be completed. PDF comment - + p. %1$d - + PDF page %1$d - + Page %1$d - %2$s - + Page %1$s of %2$d - + Pages %1$s of %2$d PDF saved PDF tools Pencil Preparing selection - + Preparing %1$s Previous page Previous search result @@ -1829,14 +1875,14 @@ Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in. Reader AI features are hidden. Desktop AI is not configured for this build. - Applies to vertical reading mode. + Applies to vertical reading and two-page spreads. Round highlighter - + Saved to %1$s Scroll Search in PDF Select text - + Selected %1$s Show search results Sign in with Google to use this feature on desktop. @@ -1847,12 +1893,12 @@ Text note text note Text style - + Thickness %1$s TOC Type to search this PDF Untitled - View Pro and credits + View account & credits Voice cache cleared Zoom Zoom in @@ -1871,7 +1917,7 @@ Folder Browse Categories - + Ch. %1$d Chapter Turns Choose font @@ -1886,7 +1932,7 @@ Covers Custom colors Custom theme preview - + Decrease %1$s Define page This removes the highlight and its note. @@ -1903,9 +1949,9 @@ Fixed-layout appearance Folder is empty No supported files or subfolders are available here. - + %1$s, %2$s - + %1$s - %2$s Hide filters Hide reader tools @@ -1914,21 +1960,23 @@ Import books Import folder Imported fonts - + %1$s %2$s - + Increase %1$s Jump history Layout and Spacing Import files into app storage or add a folder to read files in place. Browse your collection - + + AI keys + Smart %1$d - + Unread %1$d - + In progress %1$d - + Complete %1$d List Navigation @@ -1947,7 +1995,7 @@ No supported files were imported. Catalog - + Delete "%1$s"? Streamed books from this catalog may stop opening if credentials change later. No catalogs Add an OPDS catalog to browse remote books. @@ -1965,12 +2013,12 @@ Pin Reader-managed PDF tools Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader. - + %1$s %2$s of %3$d (%4$d%%) Reader toolbar defaults are managed from the reader on this platform. Reader tools Save image - + Search: %1$s Search in reader Search settings @@ -2004,6 +2052,6 @@ Visible Replace only what is spoken Reader text, highlights, and locations stay unchanged. - + %1$s -> %2$s 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 1b82a8c..9c0a6fd 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt @@ -21,6 +21,8 @@ data class BookMetadata( var isRecent: Boolean = true, var isDeleted: Boolean = false, val lastModifiedTimestamp: Long = 0L, + val readingPositionModifiedTimestamp: Long = 0L, + val annotationModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, val fileContentModifiedTimestamp: Long = 0L, diff --git a/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt b/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt index d4e3576..b129adc 100644 --- a/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/GoogleDriveRepository.kt @@ -12,7 +12,8 @@ data class DriveFileList( data class DriveFile( val id: String, - val name: String + val name: String, + val modifiedTimeMillis: Long = 0L ) data class ShelfMetadata( @@ -75,4 +76,4 @@ class GoogleDriveRepository { fun handleSignInResult(data: Intent?): Boolean { return false } -} \ No newline at end of file +} diff --git a/app/src/proTest/java/com/aryan/reader/data/FirestoreRepositoryMappingTest.kt b/app/src/proTest/java/com/aryan/reader/data/FirestoreRepositoryMappingTest.kt new file mode 100644 index 0000000..b210d07 --- /dev/null +++ b/app/src/proTest/java/com/aryan/reader/data/FirestoreRepositoryMappingTest.kt @@ -0,0 +1,30 @@ +package com.aryan.reader.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirestoreRepositoryMappingTest { + @Test + fun `book metadata map includes content reading and annotation timestamps`() { + val metadata = BookMetadata( + bookId = "book-1", + displayName = "Book.epub", + type = "EPUB", + lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 1_750L, + annotationModifiedTimestamp = 1_650L, + fileContentModifiedTimestamp = 1_500L + ) + + val fields = metadata.toFirestoreMap(originDeviceId = "device-1") + + assertTrue(fields.containsKey("fileContentModifiedTimestamp")) + assertTrue(fields.containsKey("readingPositionModifiedTimestamp")) + assertTrue(fields.containsKey("annotationModifiedTimestamp")) + assertEquals(1_500L, fields["fileContentModifiedTimestamp"]) + assertEquals(1_750L, fields["readingPositionModifiedTimestamp"]) + assertEquals(1_650L, fields["annotationModifiedTimestamp"]) + assertEquals("device-1", fields["originDeviceId"]) + } +} diff --git a/app/src/proTest/java/com/aryan/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt b/app/src/proTest/java/com/aryan/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt new file mode 100644 index 0000000..b6ca934 --- /dev/null +++ b/app/src/proTest/java/com/aryan/reader/data/GoogleDriveRepositoryUploadMetadataTest.kt @@ -0,0 +1,18 @@ +package com.aryan.reader.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GoogleDriveRepositoryUploadMetadataTest { + @Test + fun `drive upload metadata writes app data parent only on create`() { + val createMetadata = googleDriveUploadMetadata("book.epub", isCreate = true) + val updateMetadata = googleDriveUploadMetadata("book.epub", isCreate = false) + + assertEquals("book.epub", createMetadata.name) + assertEquals(listOf("appDataFolder"), createMetadata.parents) + assertEquals("book.epub", updateMetadata.name) + assertNull(updateMetadata.parents) + } +} diff --git a/app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt b/app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt new file mode 100644 index 0000000..6e35276 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt @@ -0,0 +1,22 @@ +package com.aryan.reader + +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidLegalLinksTest { + @Test + fun `android oss flavor maps to oss legal pages`() { + val links = legalLinksForAndroidFlavor("oss") + + assertTrue(links.privacyPolicyUrl.endsWith("/oss-privacy-policy.html")) + assertTrue(links.termsUrl.endsWith("/oss-terms-of-service.html")) + } + + @Test + fun `android pro flavor maps to standard legal pages`() { + val links = legalLinksForAndroidFlavor("pro") + + assertTrue(links.privacyPolicyUrl.endsWith("/privacy-policy.html")) + assertTrue(links.termsUrl.endsWith("/terms-and-conditions.html")) + } +} diff --git a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt index d50c511..dcd409f 100644 --- a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt @@ -2,6 +2,7 @@ package com.aryan.reader import com.aryan.reader.shared.SharedSettingsAction import com.aryan.reader.shared.SharedSettingsDestination +import com.aryan.reader.shared.SharedFeaturePolicy import com.aryan.reader.shared.SharedSettingsHubModel import com.aryan.reader.shared.SharedSettingsItemModel import com.aryan.reader.shared.sharedSettingsHubModel @@ -37,24 +38,23 @@ class AndroidSettingsHubModelsTest { @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 + val input = androidSettingsHubInput( + uiState = ReaderScreenState( + currentUser = UserData( + uid = "user-id", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" ), - isOssBuild = true, - isOfflineBuild = false, - isDebugBuild = true - ) + isProUser = true, + isSyncEnabled = true, + isFolderSyncEnabled = true + ), + isOssBuild = true, + isOfflineBuild = false, + isDebugBuild = true ) + val model = sharedSettingsHubModel(input) val actions = model.visibleNestedActions() assertTrue(SharedSettingsAction.AI_SETTINGS in actions) @@ -65,6 +65,7 @@ class AndroidSettingsHubModelsTest { assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions) assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions) assertTrue(SharedSettingsAction.SUPPORT in actions) + assertEquals(SharedFeaturePolicy.OssOnline, input.featurePolicy) assertEquals( "TTS & AI", model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title diff --git a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt index 2375e83..189b743 100644 --- a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt +++ b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt @@ -10,6 +10,22 @@ import org.junit.Test class AndroidStringFormatResourcesTest { + @Test + fun `vietnamese strings cover translatable base resources`() { + val resDirectory = findResDirectory() + val baseNames = readResourceNames( + stringsFile = File(resDirectory, "values/strings.xml"), + includeNonTranslatable = false + ) + val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml")) + val missingNames = baseNames.filterNot { it in vietnameseNames } + + assertTrue( + "Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}", + missingNames.isEmpty() + ) + } + @Test fun `localized formatted strings use valid formatter syntax`() { val resDirectory = findResDirectory() @@ -48,6 +64,28 @@ class AndroidStringFormatResourcesTest { ).first { it.isDirectory } } + private fun readResourceNames( + stringsFile: File, + includeNonTranslatable: Boolean = true + ): List { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(stringsFile) + val nodes = document.documentElement.childNodes + + return buildList { + for (index in 0 until nodes.length) { + val node = nodes.item(index) + val attributes = node.attributes ?: continue + val name = attributes.getNamedItem("name")?.nodeValue ?: continue + val translatable = attributes.getNamedItem("translatable")?.nodeValue + if (includeNonTranslatable || translatable != "false") { + add(name) + } + } + } + } + private fun readStringResources(stringsFile: File): Map { val document = DocumentBuilderFactory.newInstance() .newDocumentBuilder() diff --git a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt index a69cce7..bc4cd30 100644 --- a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt +++ b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt @@ -99,6 +99,19 @@ class AppLanguageOptionsTest { assertEquals("true", autoStoreLocales!!.androidAttribute("value")) } + @Test + fun `android manifest exposes cbt comic archive mime types`() { + val mimeTypes = readAndroidManifest() + .getElementsByTagName("data") + .asElements() + .mapNotNull { it.androidAttribute("mimeType") } + + assertTrue("application/x-cbt" in mimeTypes) + assertTrue("application/vnd.comicbook+tar" in mimeTypes) + assertTrue("application/x-tar" in mimeTypes) + assertTrue("application/tar" in mimeTypes) + } + private fun readLocaleConfigTags(): List { val localeConfig = listOf( File("src/main/res/xml/locales_config.xml"), diff --git a/app/src/test/java/com/aryan/reader/BookReplacementHtmlTest.kt b/app/src/test/java/com/aryan/reader/BookReplacementHtmlTest.kt new file mode 100644 index 0000000..a5f910e --- /dev/null +++ b/app/src/test/java/com/aryan/reader/BookReplacementHtmlTest.kt @@ -0,0 +1,73 @@ +package com.aryan.reader + +import com.aryan.reader.shared.ReaderBookReplacementPreferences +import com.aryan.reader.shared.ReaderWordReplacementRule +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BookReplacementHtmlTest { + @Test + fun `html replacement rewrites visible text for matching book`() { + val document = Jsoup.parse( + """ + + +

Alice & Alice

+ Alice link + + + """.trimIndent(), + ) + val preferences = ReaderBookReplacementPreferences( + fileRules = mapOf( + "book" to listOf(rule(from = "Alice", to = "Alicia")), + ), + ) + + val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book") + + assertTrue(changed) + assertEquals("Alicia & Alicia", document.selectFirst("p")?.text()) + assertEquals("Alicia link", document.selectFirst("a")?.text()) + assertEquals("chapter.xhtml", document.selectFirst("a")?.attr("href")) + } + + @Test + fun `html replacement skips blocked script text`() { + val document = Jsoup.parse( + """ + + +

Alice

+ + + + """.trimIndent(), + ) + val preferences = ReaderBookReplacementPreferences( + fileRules = mapOf( + "book" to listOf(rule(from = "Alice", to = "Alicia")), + ), + ) + + val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book") + + assertTrue(changed) + assertEquals("Alicia", document.selectFirst("p")?.text()) + assertTrue(document.selectFirst("script")?.html()?.contains("Alice") == true) + } + + private fun rule( + id: String = "rule", + from: String, + to: String, + ): ReaderWordReplacementRule { + return ReaderWordReplacementRule( + id = id, + from = from, + to = to, + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/CloudEpubAnnotationMetadataTest.kt b/app/src/test/java/com/aryan/reader/CloudEpubAnnotationMetadataTest.kt new file mode 100644 index 0000000..b26d7b9 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/CloudEpubAnnotationMetadataTest.kt @@ -0,0 +1,97 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookMetadata +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 CloudEpubAnnotationMetadataTest { + @Test + fun `remote epub annotations fill local null annotation fields without changing timestamp`() { + val local = localBook( + highlightsJson = null, + bookmarksJson = null, + lastModifiedTimestamp = 2_000L + ) + val remote = remoteBook( + highlightsJson = """[{"cfi":"/4/2:1"}]""", + bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""", + lastModifiedTimestamp = 3_000L + ) + + val merged = local.mergeRemoteEpubAnnotationMetadata(remote) + + assertEquals(remote.highlightsJson, merged.highlightsJson) + assertEquals(remote.bookmarksJson, merged.bookmarksJson) + assertEquals(2_000L, merged.lastModifiedTimestamp) + } + + @Test + fun `explicit local empty epub annotations are not replaced by remote annotations`() { + val local = localBook( + highlightsJson = "[]", + bookmarksJson = "[]" + ) + val remote = remoteBook( + highlightsJson = """[{"cfi":"/4/2:1"}]""", + bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""" + ) + + val merged = local.mergeRemoteEpubAnnotationMetadata(remote) + + assertEquals("[]", merged.highlightsJson) + assertEquals("[]", merged.bookmarksJson) + } + + @Test + fun `non epub books do not use epub annotation preservation guard`() { + val local = localBook(type = FileType.PDF, highlightsJson = null) + val remote = remoteBook(type = FileType.PDF.name, highlightsJson = """[{"cfi":"/4/2:1"}]""") + + assertFalse(local.needsRemoteEpubAnnotationMetadataGuard()) + assertEquals(local, local.mergeRemoteEpubAnnotationMetadata(remote)) + } + + @Test + fun `blank and empty annotation json are equivalent noops`() { + assertTrue(annotationJsonEquivalentForNoop(null, "[]")) + assertTrue(annotationJsonEquivalentForNoop("", "[]")) + assertFalse(annotationJsonEquivalentForNoop("""[{"id":"h1"}]""", "[]")) + } + + private fun localBook( + type: FileType = FileType.EPUB, + highlightsJson: String? = null, + bookmarksJson: String? = null, + lastModifiedTimestamp: Long = 1_000L + ): RecentFileItem { + return RecentFileItem( + bookId = "book-1", + uriString = "content://book", + type = type, + displayName = "Book.epub", + timestamp = 1_000L, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + highlightsJson = highlightsJson + ) + } + + private fun remoteBook( + type: String = FileType.EPUB.name, + highlightsJson: String? = null, + bookmarksJson: String? = null, + lastModifiedTimestamp: Long = 2_000L + ): BookMetadata { + return BookMetadata( + bookId = "book-1", + displayName = "Book.epub", + type = type, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + highlightsJson = highlightsJson + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisionsTest.kt b/app/src/test/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisionsTest.kt new file mode 100644 index 0000000..4013cba --- /dev/null +++ b/app/src/test/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisionsTest.kt @@ -0,0 +1,167 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookMetadata +import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CloudPdfAnnotationSidecarDecisionsTest { + @Test + fun `layout-only sidecar does not block newer remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = false, + inkTimestamp = 0L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = true, + layoutTimestamp = 2_000L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertFalse(localShouldUpload) + assertTrue( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer local ink payload uploads instead of downloading remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = true, + inkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = true, + layoutTimestamp = 2_500L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertTrue(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer local deletion tombstone uploads instead of downloading remote annotations`() { + val local = AndroidPdfCloudSidecarState( + hasInk = false, + inkTimestamp = 0L, + hasDeletedInk = true, + deletedInkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = false, + layoutTimestamp = 0L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + + assertTrue(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 1_500L + ) + ) + } + + @Test + fun `newer remote metadata alone does not make equal annotation payload download`() { + val local = AndroidPdfCloudSidecarState( + hasInk = true, + inkTimestamp = 2_000L, + hasRichText = false, + richTextTimestamp = 0L, + hasLayout = false, + layoutTimestamp = 0L, + hasTextBoxes = false, + textBoxesTimestamp = 0L, + hasHighlights = false, + highlightsTimestamp = 0L + ) + + val localShouldUpload = shouldUploadLocalPdfCloudAnnotations( + localSidecars = local, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 2_000L + ) + + assertFalse(localShouldUpload) + assertFalse( + shouldDownloadRemotePdfCloudAnnotations( + localSidecars = local, + localAnnotationsShouldUpload = localShouldUpload, + remoteHasAnnotations = true, + remoteAnnotationModifiedTimestamp = 2_000L + ) + ) + } + + @Test + fun `annotation freshness does not fall back to book metadata timestamp`() { + val remote = BookMetadata( + bookId = "book-1", + lastModifiedTimestamp = 5_000L, + hasAnnotations = true + ) + + assertEquals(0L, remote.effectiveAnnotationModifiedTimestamp()) + assertEquals(3_000L, remote.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L)) + } + + @Test + fun `empty sidecar placeholder is not syncable annotation payload`() { + assertFalse(tempSidecar("[]").hasSyncableCloudAnnotationPayload()) + assertFalse(tempSidecar("{}").hasSyncableCloudAnnotationPayload()) + assertTrue(tempSidecar("[{\"pageIndex\":0}]").hasSyncableCloudAnnotationPayload()) + } + + private fun tempSidecar(content: String): File { + return File.createTempFile("cloud-sidecar", ".json").apply { + writeText(content) + deleteOnExit() + } + } +} diff --git a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt index 5464efd..4456bf8 100644 --- a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt +++ b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt @@ -34,8 +34,10 @@ class FileTypeResolverTest { assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain")) assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json")) assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip")) + assertEquals(FileType.CBT, resolveFileTypeFromMetadata("comic.cbt", "application/x-tar")) assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip")) assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip")) + assertNull(resolveFileTypeFromMetadata("archive.tar", "application/x-tar")) } @Test @@ -56,6 +58,7 @@ class FileTypeResolverTest { fun `plain txt remains txt when inner extension is unsupported`() { assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt")) assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx")) + assertEquals(FileType.CBT, resolveFileTypeFromName("comic.cbt")) assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt")) assertNull(resolveFileTypeFromName("archive.zip")) } diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index 3eab3fe..182a5b7 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -8,11 +8,15 @@ import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.credentials.CredentialManager +import androidx.lifecycle.ViewModel import androidx.work.WorkManager 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.pdf.data.PdfMetaDao +import com.aryan.reader.pdf.data.PdfTextDao +import com.aryan.reader.pdf.data.PdfTextDatabase import com.aryan.reader.tts.TtsController import com.aryan.reader.tts.TtsPlaybackManager import io.mockk.* @@ -24,6 +28,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.* import org.junit.After +import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -34,7 +39,7 @@ import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class MainViewModelTest { - private val testDispatcher = StandardTestDispatcher() + private lateinit var testDispatcher: TestDispatcher private lateinit var viewModel: MainViewModel private lateinit var mockApplication: Application @@ -50,8 +55,26 @@ class MainViewModelTest { private val tagsFlow = MutableStateFlow>(emptyList()) private val tagRefsFlow = MutableStateFlow>(emptyList()) + companion object { + @JvmStatic + @AfterClass + fun resetMainDispatcher() { + Dispatchers.resetMain() + } + } + + private class TestMainViewModel(application: Application) : MainViewModel(application) { + fun clearForTest() { + ViewModel::class.java + .getDeclaredMethod("clear\$lifecycle_viewmodel_release") + .invoke(this) + } + } + @Before fun setup() { + testDispatcher = StandardTestDispatcher() + recentFilesFlow.value = emptyList() shelvesFlow.value = emptyList() shelfRefsFlow.value = emptyList() @@ -100,6 +123,11 @@ class MainViewModelTest { val mockBookCacheDb = mockk(relaxed = true) every { mockBookCacheDb.bookCacheDao() } returns mockk(relaxed = true) every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb + mockkObject(PdfTextDatabase.Companion) + val mockPdfTextDb = mockk(relaxed = true) + every { mockPdfTextDb.pdfTextDao() } returns mockk(relaxed = true) + every { mockPdfTextDb.pdfMetaDao() } returns mockk(relaxed = true) + every { PdfTextDatabase.getDatabase(any()) } returns mockPdfTextDb mockkObject(WorkManager.Companion) val mockWorkManager = mockk(relaxed = true) @@ -114,6 +142,7 @@ class MainViewModelTest { mockkConstructor(FeedbackRepository::class) mockkConstructor(FontsRepository::class) mockkConstructor(TtsController::class) + mockkConstructor(BookImporter::class) every { anyConstructed().proUpgradeState } returns billingStateFlow every { anyConstructed().initializeConnection() } just Runs @@ -126,6 +155,7 @@ class MainViewModelTest { every { anyConstructed().launchPurchaseFlow(any(), any(), any()) } just Runs every { anyConstructed().getSignedInUser() } returns null every { anyConstructed().observeAuthState() } returns flowOf(null) + every { anyConstructed().removeListener(any()) } just Runs every { anyConstructed().init() } just Runs every { anyConstructed().ttsState } returns ttsStateFlow every { anyConstructed().connect() } just Runs @@ -143,21 +173,30 @@ class MainViewModelTest { coEvery { anyConstructed().removeBooksFromShelf(any(), any()) } just Runs coEvery { anyConstructed().addBooksToShelf(any(), any()) } just Runs coEvery { anyConstructed().deleteShelf(any()) } just Runs + coEvery { anyConstructed().deleteFilePermanently(any()) } just Runs + coEvery { anyConstructed().deleteBookByUriString(any()) } returns true every { anyConstructed().getAllFonts() } returns customFontsFlow coEvery { anyConstructed().deleteFont(any()) } just Runs - viewModel = MainViewModel(mockApplication) + viewModel = TestMainViewModel(mockApplication) } @After fun tearDown() { - Dispatchers.resetMain() - unmockkAll() + try { + if (::viewModel.isInitialized) { + testDispatcher.scheduler.advanceUntilIdle() + (viewModel as? TestMainViewModel)?.clearForTest() + testDispatcher.scheduler.advanceUntilIdle() + } + } finally { + unmockkAll() + } } @Test - fun `search query updates uiState when search is active`() = runTest { + fun `search query updates uiState when search is active`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -173,7 +212,7 @@ class MainViewModelTest { } @Test - fun `setSearchActive false clears the search query`() = runTest { + fun `setSearchActive false clears the search query`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -187,7 +226,7 @@ class MainViewModelTest { } @Test - fun `search query change is ignored while search is inactive`() = runTest { + fun `search query change is ignored while search is inactive`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -199,7 +238,7 @@ class MainViewModelTest { } @Test - fun `switching theme updates internal state and preferences`() = runTest { + fun `switching theme updates internal state and preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -212,7 +251,7 @@ class MainViewModelTest { } @Test - fun `setAppFontPreference persists app font preference`() = runTest { + fun `setAppFontPreference persists app font preference`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -227,7 +266,7 @@ class MainViewModelTest { } @Test - fun `deleteFont resets matching app custom font preference`() = runTest { + fun `deleteFont resets matching app custom font preference`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -243,7 +282,59 @@ class MainViewModelTest { } @Test - fun `setTabsEnabled persists to shared preferences`() = runTest { + fun `deleteFonts deletes unique selected fonts and resets matching app custom font preference`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setAppFontPreference(AppFontPreference.custom("font-b")) + viewModel.deleteFonts(listOf("font-a", "font-b", "font-a", "")) + advanceUntilIdle() + + coVerify(exactly = 1) { anyConstructed().deleteFont("font-a") } + coVerify(exactly = 1) { anyConstructed().deleteFont("font-b") } + coVerify(exactly = 0) { anyConstructed().deleteFont("") } + assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference) + verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) } + verify { mockEditor.remove("app_font_custom_id") } + } + + @Test + fun `importFonts imports every selected font`() = runTest(testDispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val firstUri = mockk() + val secondUri = mockk() + val firstFont = CustomFontEntity( + id = "font-1", + displayName = "First", + fileName = "font_1.ttf", + fileExtension = "ttf", + path = "/fonts/font_1.ttf", + timestamp = 1L + ) + val secondFont = CustomFontEntity( + id = "font-2", + displayName = "Second", + fileName = "font_2.otf", + fileExtension = "otf", + path = "/fonts/font_2.otf", + timestamp = 2L + ) + coEvery { anyConstructed().importFont(firstUri) } returns Result.success(firstFont) + coEvery { anyConstructed().importFont(secondUri) } returns Result.success(secondFont) + + viewModel.importFonts(listOf(firstUri, secondUri)) + advanceUntilIdle() + + coVerify(exactly = 1) { anyConstructed().importFont(firstUri) } + coVerify(exactly = 1) { anyConstructed().importFont(secondUri) } + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `setTabsEnabled persists to shared preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -256,7 +347,7 @@ class MainViewModelTest { } @Test - fun `setRenderMode persists mode without touching saved epub position`() = runTest { + fun `setRenderMode persists mode without touching saved epub position`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -272,7 +363,7 @@ class MainViewModelTest { } @Test - fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest { + fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest(testDispatcher) { val uriString = "content://books/one" val uri = mockUri(uriString) val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14) @@ -301,7 +392,7 @@ class MainViewModelTest { } @Test - fun `setRecentFilesLimit persists and limits visible home recents`() = runTest { + fun `setRecentFilesLimit persists and limits visible home recents`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -318,7 +409,7 @@ class MainViewModelTest { } @Test - fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest { + fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -339,7 +430,35 @@ class MainViewModelTest { } @Test - fun `screen capture protection persists and updates state`() = runTest { + fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) { + val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub" + val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}""" + every { + mockPrefs.getStringSet("pending_external_file_removals", any()) + } returns mutableSetOf(pendingEntry) + every { mockPrefs.getString("last_open_book_id", null) } returns "external-book" + every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name + + val restored = TestMainViewModel(mockApplication) + try { + advanceUntilIdle() + + coVerify { + anyConstructed().deleteFilePermanently(listOf("external-book")) + } + coVerify { + anyConstructed().deleteBookByUriString(pendingUri) + } + verify(atLeast = 1) { mockEditor.remove("last_open_book_id") } + verify(atLeast = 1) { mockEditor.remove("last_open_file_type") } + verify { mockEditor.remove("pending_external_file_removals") } + } finally { + restored.clearForTest() + } + } + + @Test + fun `screen capture protection persists and updates state`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -358,7 +477,7 @@ class MainViewModelTest { } @Test - fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest { + fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -380,7 +499,7 @@ class MainViewModelTest { } @Test - fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest { + fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -393,7 +512,7 @@ class MainViewModelTest { } @Test - fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest { + fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -407,7 +526,7 @@ class MainViewModelTest { } @Test - fun `create shelf dialog state opens and dismisses`() = runTest { + fun `create shelf dialog state opens and dismisses`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -422,7 +541,7 @@ class MainViewModelTest { } @Test - fun `selectAllRecentFiles toggles only visible recent home items`() = runTest { + fun `selectAllRecentFiles toggles only visible recent home items`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -445,7 +564,7 @@ class MainViewModelTest { } @Test - fun `selectAllLibraryFiles toggles all filtered library items`() = runTest { + fun `selectAllLibraryFiles toggles all filtered library items`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -465,7 +584,7 @@ class MainViewModelTest { } @Test - fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest { + fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -483,7 +602,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest { + fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -503,7 +622,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest { + fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -525,7 +644,7 @@ class MainViewModelTest { } @Test - fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest { + fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -544,7 +663,7 @@ class MainViewModelTest { } @Test - fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest { + fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -564,7 +683,7 @@ class MainViewModelTest { } @Test - fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest { + fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -586,7 +705,7 @@ class MainViewModelTest { } @Test - fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest { + fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -607,16 +726,20 @@ class MainViewModelTest { } @Test - fun `saved library file filters drop stale unknown values during restore`() = runTest { + fun `saved library file filters drop stale unknown values during restore`() = runTest(testDispatcher) { 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) + val restored = TestMainViewModel(mockApplication) + try { + assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes) + } finally { + restored.clearForTest() + testDispatcher.scheduler.advanceUntilIdle() + } } @Test - fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest { + fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -641,7 +764,7 @@ class MainViewModelTest { } @Test - fun `tag selection ignores empty targets and closes after opening`() = runTest { + fun `tag selection ignores empty targets and closes after opening`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -659,7 +782,7 @@ class MainViewModelTest { } @Test - fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest { + fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -679,7 +802,7 @@ class MainViewModelTest { } @Test - fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest { + fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -701,7 +824,7 @@ class MainViewModelTest { } @Test - fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest { + fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -723,7 +846,7 @@ class MainViewModelTest { } @Test - fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest { + fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -740,7 +863,7 @@ class MainViewModelTest { } @Test - fun `shelf navigation sets library landing state and can be cleared`() = runTest { + fun `shelf navigation sets library landing state and can be cleared`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -759,7 +882,7 @@ class MainViewModelTest { } @Test - fun `clearShelfContextualAction clears selected shelves`() = runTest { + fun `clearShelfContextualAction clears selected shelves`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -776,7 +899,7 @@ class MainViewModelTest { } @Test - fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest { + fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -803,7 +926,7 @@ class MainViewModelTest { } @Test - fun `add books mode resets selection and tracks source changes`() = runTest { + fun `add books mode resets selection and tracks source changes`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -837,7 +960,7 @@ class MainViewModelTest { } @Test - fun `toggleBookSelectionForAdding toggles individual books`() = runTest { + fun `toggleBookSelectionForAdding toggles individual books`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -852,7 +975,7 @@ class MainViewModelTest { } @Test - fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest { + fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -876,7 +999,7 @@ class MainViewModelTest { } @Test - fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest { + fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -893,7 +1016,7 @@ class MainViewModelTest { } @Test - fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest { + fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -915,7 +1038,7 @@ class MainViewModelTest { } @Test - fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest { + fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -951,7 +1074,7 @@ class MainViewModelTest { } @Test - fun `setAppSeedColor can clear a selected seed color`() = runTest { + fun `setAppSeedColor can clear a selected seed color`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -968,7 +1091,7 @@ class MainViewModelTest { } @Test - fun `addCustomAppTheme replaces existing theme with the same id`() = runTest { + fun `addCustomAppTheme replaces existing theme with the same id`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -985,7 +1108,7 @@ class MainViewModelTest { } @Test - fun `banner message logic works correctly`() = runTest { + fun `banner message logic works correctly`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } @@ -1004,7 +1127,7 @@ class MainViewModelTest { } @Test - fun `persistent banner is not auto dismissed`() = runTest { + fun `persistent banner is not auto dismissed`() = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } diff --git a/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt b/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt index e6339e5..25bbdf4 100644 --- a/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt +++ b/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt @@ -12,7 +12,17 @@ class ReaderBrightnessSettingsTest { assertTrue(defaults.useSystemBrightness) assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f) - assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f) + assertEquals(0.01f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f) + assertEquals(0.02f, defaults.copy(customBrightness = 0.02f).safeCustomBrightness, 0.0001f) + assertEquals(0.23f, defaults.copy(customBrightness = 0.234f).safeCustomBrightness, 0.0001f) assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f) } + + @Test + fun `brightness step controls move by one percent and clamp`() { + assertEquals(0.74f, stepReaderBrightness(0.75f, -1), 0.0001f) + assertEquals(0.76f, stepReaderBrightness(0.75f, 1), 0.0001f) + assertEquals(0.01f, stepReaderBrightness(0.01f, -1), 0.0001f) + assertEquals(1f, stepReaderBrightness(1f, 1), 0.0001f) + } } diff --git a/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt b/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt index 1283ade..edd8bf1 100644 --- a/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt +++ b/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt @@ -80,6 +80,68 @@ class ReaderSliderChromeStateTest { ) } + @Test + fun `one based slider stepping clamps to epub page range`() { + assertEquals( + 1, + readerSliderStepPage( + currentPage = 1, + delta = -1, + minPage = 1, + maxPage = 20 + ) + ) + assertEquals( + 11, + readerSliderStepPage( + currentPage = 10, + delta = 1, + minPage = 1, + maxPage = 20 + ) + ) + assertEquals( + 20, + readerSliderStepPage( + currentPage = 20, + delta = 1, + minPage = 1, + maxPage = 20 + ) + ) + } + + @Test + fun `zero based slider stepping clamps to pdf display page range`() { + assertEquals( + 0, + readerSliderStepPage( + currentPage = 0, + delta = -1, + minPage = 0, + maxPage = 9 + ) + ) + assertEquals( + 6, + readerSliderStepPage( + currentPage = 5, + delta = 1, + minPage = 0, + maxPage = 9 + ) + ) + assertEquals( + 9, + readerSliderStepPage( + currentPage = 9, + delta = 1, + minPage = 0, + maxPage = 9 + ) + ) + } + @Test fun `slider content color falls back on light page when theme text is low contrast`() { val colors = readerSliderChromeColors( diff --git a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt index b55a261..8e31f20 100644 --- a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt +++ b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt @@ -3,6 +3,8 @@ 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.data.toBookMetadata +import com.aryan.reader.data.toRecentFileItem import com.aryan.reader.shared.ReaderFeatureSurface import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.SharedReaderScreenState @@ -46,6 +48,48 @@ class SharedModelMappersTest { assertEquals(listOf(tag), mapped.tags) } + @Test + fun `book mapper carries android epub block locator through shared model`() { + val original = recentFile( + id = "book", + type = FileType.EPUB, + lastChapterIndex = 3, + lastPage = 18, + lastPositionCfi = "android-locator:3:44:120", + locatorBlockIndex = 44, + locatorCharOffset = 120 + ) + + val shared = original.toSharedBookItem() + val mapped = shared.toRecentFileItem() + + assertEquals(3, shared.readerPosition?.chapterIndex) + assertEquals(18, shared.readerPosition?.pageIndex) + assertEquals(44, shared.readerPosition?.blockIndex) + assertEquals(120, shared.readerPosition?.charOffset) + assertEquals(original.lastChapterIndex, mapped.lastChapterIndex) + assertEquals(original.lastPage, mapped.lastPage) + assertEquals(original.lastPositionCfi, mapped.lastPositionCfi) + assertEquals(original.locatorBlockIndex, mapped.locatorBlockIndex) + assertEquals(original.locatorCharOffset, mapped.locatorCharOffset) + } + + @Test + fun `android cloud metadata preserves file content timestamp`() { + val original = recentFile( + id = "book", + type = FileType.EPUB, + fileContentModifiedTimestamp = 1_500L + ) + + val metadata = original.toBookMetadata() + val restored = metadata.toRecentFileItem() + + assertEquals(1_500L, metadata.fileContentModifiedTimestamp) + assertEquals(1_500L, restored.fileContentModifiedTimestamp) + assertFalse(restored.isAvailable) + } + @Test fun `shared projection state maps shelves tabs selections and tags back to android state`() { val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L) @@ -92,6 +136,41 @@ class SharedModelMappersTest { assertEquals(listOf(tag), android.allTags) } + @Test + fun `shared projection state reuses mapped android book instances by id`() { + val book = recentFile("book") + 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), + shelves = listOf(sharedShelf), + openTabs = listOf(sharedBook), + booksAvailableForAdding = listOf(sharedBook) + ) + + val android = projected.toAndroidReaderScreenState( + base = ReaderScreenState(), + androidBooksById = mapOf(book.bookId to book) + ) + + val mappedBook = android.rawLibraryFiles.single() + assertSame(book, mappedBook) + assertSame(mappedBook, android.recentFiles.single()) + assertSame(mappedBook, android.allRecentFiles.single()) + assertSame(mappedBook, android.shelves.single().books.single()) + assertSame(mappedBook, android.shelves.single().directBooks.single()) + assertSame(mappedBook, android.openTabs.single()) + assertSame(mappedBook, android.booksAvailableForAdding.single()) + } + @Test fun `enum filter and folder mappers round trip between android and shared`() { val filters = LibraryFilters( @@ -115,6 +194,7 @@ class SharedModelMappersTest { assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters()) assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder()) assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES) + assertTrue(FileType.CBT in PDF_VIEWER_FILE_TYPES) assertEquals(ReaderFeatureSurface.PDF_VIEWER, FileType.PPTX.readerSurfaceOnAndroid()) assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES) assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES) @@ -133,6 +213,18 @@ class SharedModelMappersTest { assertEquals(listOf(tag), tagged.single().tags) } + @Test + fun `tag resolver reuses book item when resolved tags are unchanged`() { + val file = recentFile("book") + + val resolved = listOf(file).withResolvedTags( + dbTags = emptyList(), + tagRefs = emptyList() + ) + + assertSame(file, resolved.single()) + } + private fun recentFile( id: String, type: FileType = FileType.EPUB, @@ -141,6 +233,12 @@ class SharedModelMappersTest { isAvailable: Boolean = true, bookmarksJson: String? = null, sourceFolderUri: String? = null, + lastChapterIndex: Int? = null, + lastPage: Int? = null, + lastPositionCfi: String? = null, + locatorBlockIndex: Int? = null, + locatorCharOffset: Int? = null, + fileContentModifiedTimestamp: Long = 0L, tags: List = emptyList() ) = RecentFileItem( bookId = id, @@ -152,6 +250,12 @@ class SharedModelMappersTest { bookmarksJson = bookmarksJson, sourceFolderUri = sourceFolderUri, customName = customName, + lastChapterIndex = lastChapterIndex, + lastPage = lastPage, + lastPositionCfi = lastPositionCfi, + locatorBlockIndex = locatorBlockIndex, + locatorCharOffset = locatorCharOffset, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, tags = tags ) diff --git a/app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt b/app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt new file mode 100644 index 0000000..56c4815 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt @@ -0,0 +1,69 @@ +package com.aryan.reader + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncedFolderPrefsTest { + @Test + fun `missing local sync flag defaults enabled`() { + val folders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = """ + [ + { + "uri": "content://folder", + "name": "Books", + "lastScanTime": 12, + "allowedFileTypes": ["PDF"] + } + ] + """.trimIndent(), + legacyUri = null, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + + assertTrue(folders.single().localSyncEnabled) + assertTrue( + SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = SyncedFolderPrefs.encodeSyncedFolders( + folders, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ), + legacyUri = null, + folderUriString = "content://folder", + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + ) + } + + @Test + fun `disabled local sync flag persists and is checked`() { + val encoded = SyncedFolderPrefs.encodeSyncedFolders( + listOf( + SyncedFolder( + uriString = "content://folder", + name = "Books", + lastScanTime = 12L, + allowedFileTypes = setOf(FileType.PDF), + localSyncEnabled = false + ) + ), + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + val decoded = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = encoded, + legacyUri = null, + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + + assertFalse(decoded.single().localSyncEnabled) + assertFalse( + SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = encoded, + legacyUri = null, + folderUriString = "content://folder", + syncableTypes = setOf(FileType.PDF, FileType.EPUB) + ) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt index bfc810b..d279997 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt @@ -56,6 +56,7 @@ class RecentFileDaoReadingPositionTest { assertEquals(58.5f, saved.progressPercentage) assertEquals(9_000L, saved.timestamp) assertEquals(9_000L, saved.lastModifiedTimestamp) + assertEquals(9_000L, saved.readingPositionModifiedTimestamp) } @Test @@ -100,6 +101,7 @@ class RecentFileDaoReadingPositionTest { assertEquals(21, item.locatorBlockIndex) assertEquals(12, item.locatorCharOffset) assertEquals(44f, item.progressPercentage) + assertEquals(3_000L, item.readingPositionModifiedTimestamp) assertTrue(item.isRecent) } 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 831c304..a7f4626 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt @@ -18,6 +18,7 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) + assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp) } @Test @@ -32,6 +33,7 @@ class RecentFileItemReadingPositionMappingTest { assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp) + assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp) } @Test @@ -101,6 +103,7 @@ class RecentFileItemReadingPositionMappingTest { locatorCharOffset = 88, progressPercentage = 61.5f, lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 1_900L, 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 2057e0e..3aa683d 100644 --- a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt +++ b/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt @@ -104,6 +104,8 @@ class RecentFilesRepositoryReadingPositionMergeTest { locatorBlockIndex = 31, locatorCharOffset = 12, progressPercentage = 82f, + lastModifiedTimestamp = 2_000L, + readingPositionModifiedTimestamp = 2_000L, isRecent = true ) ) @@ -113,6 +115,41 @@ class RecentFilesRepositoryReadingPositionMergeTest { assertEquals(31, inserted.captured.locatorBlockIndex) assertEquals(12, inserted.captured.locatorCharOffset) assertEquals(82f, inserted.captured.progressPercentage) + assertEquals(2_000L, inserted.captured.readingPositionModifiedTimestamp) + } + + @Test + fun `addRecentFile preserves newer existing reading position when incoming metadata timestamp is newer`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy( + readingPositionModifiedTimestamp = 1_800L + ) + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 3_000L, + lastChapterIndex = 1, + lastPositionCfi = "/old/remote", + locatorBlockIndex = 2, + locatorCharOffset = 3, + progressPercentage = 12f, + lastModifiedTimestamp = 3_000L, + readingPositionModifiedTimestamp = 1_200L, + isRecent = true + ) + ) + + assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi) + assertEquals(6, inserted.captured.lastChapterIndex) + assertEquals(24, inserted.captured.locatorBlockIndex) + assertEquals(44, inserted.captured.locatorCharOffset) + assertEquals(71.5f, inserted.captured.progressPercentage) + assertEquals(1_800L, inserted.captured.readingPositionModifiedTimestamp) } @Test diff --git a/app/src/test/java/com/aryan/reader/epub/EpubImportSecurityTest.kt b/app/src/test/java/com/aryan/reader/epub/EpubImportSecurityTest.kt new file mode 100644 index 0000000..fd8bee3 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epub/EpubImportSecurityTest.kt @@ -0,0 +1,132 @@ +package com.aryan.reader.epub + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class EpubImportSecurityTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `safeFileInRoot rejects traversal outside extraction root`() { + val root = temp.newFolder("root") + + assertNotNull(safeFileInRoot(root, "OEBPS/image.png")) + assertTrue(safeFileInRoot(root, "../outside.txt") == null) + } + + @Test + fun `xml parser rejects doctypes from untrusted book metadata`() { + val xml = """ + ]> + &xxe; + """.trimIndent() + + assertThrows(Exception::class.java) { + parseXMLFile(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) + } + } + + @Test + fun `odt parser skips zip entries that escape extraction root`() = runTest { + val extractionDir = temp.newFolder("odt-root") + val outside = File(extractionDir.parentFile, "odt-evil.txt") + val parser = OdtParser(contextWithCache(temp.newFolder("odt-cache"))) + + parser.createOdtBook( + inputStream = ByteArrayInputStream( + zipBytes( + "content.xml" to minimalOdtContent().toByteArray(Charsets.UTF_8), + "../odt-evil.txt" to "evil".toByteArray(Charsets.UTF_8) + ) + ), + bookId = "odt-book", + originalBookNameHint = "book.odt", + isFlat = false, + parseContent = false, + extractionDirOverride = extractionDir + ) + + assertFalse(outside.exists()) + } + + @Test + fun `fb2 parser sanitizes binary image ids before writing files`() = runTest { + val extractionDir = temp.newFolder("fb2-root") + val outside = File(extractionDir.parentFile, "fb2-evil.png") + val parser = Fb2Parser(contextWithCache(temp.newFolder("fb2-cache"))) + + parser.createFb2Book( + inputStream = ByteArrayInputStream(minimalFb2WithUnsafeImage().toByteArray(Charsets.UTF_8)), + bookId = "fb2-book", + originalBookNameHint = "book.fb2", + parseContent = true, + extractionDirOverride = extractionDir + ) + + assertFalse(outside.exists()) + assertTrue(extractionDir.listFiles().orEmpty().any { it.name.startsWith("fb2-evil_") && it.extension == "png" }) + } + + private fun contextWithCache(cacheDir: File): Context { + val context = mockk(relaxed = true) + every { context.cacheDir } returns cacheDir + return context + } + + private fun zipBytes(vararg entries: Pair): ByteArray { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + entries.forEach { (name, bytes) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return output.toByteArray() + } + + private fun minimalOdtContent(): String { + return """ + + Hello + + """.trimIndent() + } + + private fun minimalFb2WithUnsafeImage(): String { + val payload = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3, 4)) + return """ + + Unsafe image + +
+

Hello

+ +
+ + $payload +
+ """.trimIndent() + } +} diff --git a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt index 7a03a90..3b021b1 100644 --- a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt +++ b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt @@ -56,7 +56,11 @@ class SingleFileImporterTest { assertEquals("Part 1", book.chapters.single().title) assertTrue(book.chapters.single().plainTextContent.contains("First continues")) assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First <line>")) - assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile) + val metadata = File(book.extractionBasePath, "book_metadata.json") + assertTrue(metadata.isFile) + val metadataText = metadata.readText() + assertFalse(metadataText.contains("First continues")) + assertTrue(metadataText.contains("plainTextLength")) } @Test @@ -79,8 +83,29 @@ class SingleFileImporterTest { ) assertEquals(first.title, second.title) - assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent) - assertTrue(second.chapters.single().plainTextContent.contains("Cached content")) + assertEquals(first.chapters.single().plainTextLength, second.chapters.single().plainTextLength) + assertEquals("", second.chapters.single().plainTextContent) + assertTrue(File(second.extractionBasePath, second.chapters.single().htmlFilePath).readText().contains("Cached content")) + } + + @Test + fun `plain text import ignores oversized legacy cached metadata before reading it`() = runTest { + val cache = temp.newFolder("txt-cache-oversized") + val context = contextWithCache(cache) + val bookId = "oversized-cache-book" + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) + File(extractionDir, "book_metadata.json").writeText("x".repeat((2L * 1024L * 1024L + 1L).toInt())) + val importer = SingleFileImporter(context) + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream("Fresh content after oversized cache".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Fresh.txt", + bookId = bookId + ) + + assertEquals("Fresh", book.title) + assertTrue(book.chapters.single().plainTextContent.contains("Fresh content")) } @Test diff --git a/app/src/test/java/com/aryan/reader/epubreader/ChapterWebViewHighlightJsonTest.kt b/app/src/test/java/com/aryan/reader/epubreader/ChapterWebViewHighlightJsonTest.kt new file mode 100644 index 0000000..31a21bc --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epubreader/ChapterWebViewHighlightJsonTest.kt @@ -0,0 +1,38 @@ +package com.aryan.reader.epubreader + +import com.aryan.reader.shared.ReaderLocator +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChapterWebViewHighlightJsonTest { + + @Test + fun `webview highlight json keeps shared locator offsets`() { + val highlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:6:120:145", + text = "synced desktop text", + color = HighlightColor.GREEN, + chapterIndex = 6, + locator = ReaderLocator( + chapterIndex = 6, + pageIndex = 2, + startOffset = 120, + endOffset = 145, + textQuote = "synced desktop text", + cfi = "desktop:6:120:145" + ) + ) + + val obj = JSONArray(highlightsJsonForWebView(listOf(highlight))).getJSONObject(0) + val locator = obj.getJSONObject("locator") + + assertEquals("desktop:6:120:145", obj.getString("cfi")) + assertEquals("user-highlight-green", obj.getString("cssClass")) + assertEquals(6, locator.getInt("chapterIndex")) + assertEquals(120, locator.getInt("startOffset")) + assertEquals(145, locator.getInt("endOffset")) + assertEquals("synced desktop text", locator.getString("textQuote")) + } +} 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 833efee..be2db10 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -257,7 +257,8 @@ class EpubReaderBridgeAndControlsTest { val sections = epubOverflowMenuSections( hiddenTools = setOf( ReaderTool.TTS_SETTINGS.name, - ReaderTool.TTS_REPLACEMENTS.name + ReaderTool.TTS_REPLACEMENTS.name, + ReaderTool.BOOK_REPLACEMENTS.name ), hasHiddenToolbarTools = false, hasToggleReflow = false, @@ -267,6 +268,21 @@ class EpubReaderBridgeAndControlsTest { assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last()) assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections) + assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS !in sections) + } + + @Test + fun `epub overflow sections expose book replacements when visible`() { + val sections = epubOverflowMenuSections( + hiddenTools = emptySet(), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = false + ) + + assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS in sections) + assertTrue(sections.indexOf(EpubOverflowMenuSection.BOOK_REPLACEMENTS) < sections.indexOf(EpubOverflowMenuSection.TTS_SETTINGS)) } @Test diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt index 60bfc0f..afcfc10 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt @@ -40,6 +40,7 @@ class EpubReaderPreferencesAndAnnotationsTest { assertEquals(ReaderFont.ORIGINAL, format.font) assertEquals(ReaderTextAlign.DEFAULT, format.textAlign) assertNull(format.customPath) + assertFalse(loadNativeVerticalRenderer(context)) } @Test @@ -136,6 +137,7 @@ class EpubReaderPreferencesAndAnnotationsTest { saveVolumeScrollSetting(context, true) saveRemoveEdgePadding(context, true) saveFormatIsLocal(context, "book", true) + saveNativeVerticalRenderer(context, true) assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f) assertEquals(0.85f, loadTtsPitch(context), 0.0001f) @@ -149,6 +151,7 @@ class EpubReaderPreferencesAndAnnotationsTest { assertTrue(loadVolumeScrollSetting(context)) assertTrue(loadRemoveEdgePadding(context)) assertTrue(loadFormatIsLocal(context, "book")) + assertTrue(loadNativeVerticalRenderer(context)) assertEquals(0f, loadHorizontalMargin(context), 0.0001f) } diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt index a7b825a..e3a5d69 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt @@ -70,6 +70,24 @@ class EpubReaderSearchTest { assertEquals("Chunky", result.locationTitle) } + @Test + fun `search scans oversized text nodes in bounded windows`() = runTest { + val root = temp.newFolder("bounded-window") + val filler = "alpha ".repeat(7_000) + writeChapter( + root, + "chapter.xhtml", + "

${filler}Needle ${filler}pineedle ${filler}Needle

" + ) + val book = epubBook(root, listOf(chapter("ch1", "Large", "chapter.xhtml"))) + + val results = createEpubSearcher(book)("needle") + + assertEquals(2, results.size) + assertEquals(listOf(0, 1), results.map { it.occurrenceIndexInLocation }) + assertTrue(results.all { it.snippet.text.contains("Needle", ignoreCase = true) }) + } + @Test fun `search currently requires only a word start and highlights the matched substring`() = runTest { val root = temp.newFolder("word-start") diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsStateTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsStateTest.kt new file mode 100644 index 0000000..8dc7f1c --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderVisualOptionsStateTest.kt @@ -0,0 +1,47 @@ +package com.aryan.reader.epubreader + +import com.aryan.reader.shared.PageInfoMode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpubReaderVisualOptionsStateTest { + + @Test + fun `page info always show remains visible independent of reader chrome`() { + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.DEFAULT, + showReaderChrome = false + ) + ) + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.DEFAULT, + showReaderChrome = true + ) + ) + } + + @Test + fun `page info sync follows reader chrome and hidden never shows`() { + assertFalse( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.SYNC, + showReaderChrome = false + ) + ) + assertTrue( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.SYNC, + showReaderChrome = true + ) + ) + assertFalse( + shouldShowEpubPageInfoBar( + pageInfoMode = PageInfoMode.HIDDEN, + showReaderChrome = true + ) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt index 58dd627..6883660 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubTtsChunkMatchingTest.kt @@ -47,4 +47,19 @@ class EpubTtsChunkMatchingTest { ) ) } + + @Test + fun `chunk start matching accepts target offset inside matching source block`() { + val chunks = listOf( + TtsChunk("Alpha beta gamma", "/4/8/2", 10), + TtsChunk("Delta epsilon", "/4/10/2", 0) + ) + val nativeVerticalTarget = TtsChunk( + text = "", + sourceCfi = "/4/8", + startOffsetInSource = 16 + ) + + assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget)) + } } 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 6d665f5..d30c146 100644 --- a/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt +++ b/app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt @@ -193,7 +193,8 @@ class OpdsParserTest { "application/vnd.openxmlformats-officedocument.presentationml.presentation" ), OpdsAcquisition("epub", "application/epub+zip"), - OpdsAcquisition("unknown", "application/octet-stream") + OpdsAcquisition("unknown", "application/octet-stream"), + OpdsAcquisition("cbt", "application/vnd.comicbook+tar") ) val entry = OpdsEntry( id = "id", @@ -208,6 +209,7 @@ class OpdsParserTest { assertEquals("PPTX", acquisitions[2].formatName) assertEquals("TXT", acquisitions[0].formatName) assertEquals("OCTET-STREAM", acquisitions[4].formatName) + assertEquals("CBT", acquisitions[5].formatName) assertEquals(acquisitions[3], entry.bestAcquisition) } } diff --git a/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt b/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt index b081ac5..0a0699a 100644 --- a/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt +++ b/app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt @@ -102,6 +102,24 @@ class OpdsRepositoryTest { assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header)) } + @Test + fun `digest authenticator selects auth from qop list`() { + val request = Request.Builder() + .url("https://example.org/catalog/feed") + .build() + val response = responseFor( + request, + "Digest realm=\"realm\", nonce=\"abc\", qop=\"auth,auth-int\"" + ) + + val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass") + .authenticate(null, response) + val header = authenticated?.header("Authorization").orEmpty() + + assertTrue(header.contains("qop=auth")) + assertTrue(!header.contains("auth,auth-int")) + } + @Test fun `authenticator ignores unsupported challenge`() { val request = Request.Builder().url("https://example.org/feed").build() diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt new file mode 100644 index 0000000..ac5a3ce --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/AndroidHtmlResourceResolverTest.kt @@ -0,0 +1,45 @@ +package com.aryan.reader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AndroidHtmlResourceResolverTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `resolvePath returns files inside extraction root`() { + val root = temp.newFolder("book") + val image = File(root, "OEBPS/images/picture.png").apply { + parentFile?.mkdirs() + writeText("image") + } + + assertEquals( + image.canonicalPath, + AndroidHtmlResourceResolver.resolvePath( + chapterAbsPath = "OEBPS/chapter.xhtml", + extractionBasePath = root.absolutePath, + src = "images/picture.png" + ) + ) + } + + @Test + fun `resolvePath rejects paths that escape extraction root`() { + val root = temp.newFolder("book") + File(root.parentFile, "outside.png").writeText("outside") + + assertNull( + AndroidHtmlResourceResolver.resolvePath( + chapterAbsPath = "OEBPS/chapter.xhtml", + extractionBasePath = root.absolutePath, + src = "../../outside.png" + ) + ) + } +} 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 fc01dcc..5ff2ac8 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt @@ -39,6 +39,7 @@ class ContentStylerTest { ).single() as ParagraphBlock assertEquals(TextAlign.Justify, block.textAlign) + assertEquals(TextAlign.Justify, block.content.paragraphStyles.first().item.textAlign) assertEquals("p1", block.elementId) assertEquals("/4/2", block.cfi) assertEquals(7, block.startCharOffsetInSource) @@ -46,6 +47,22 @@ class ContentStylerTest { assertEquals("Aligned text", block.content.text) } + @Test + fun `paragraph styling downgrades css justify unless user explicitly forces alignment`() { + val block = styler(userTextAlign = null).style( + listOf( + paragraph( + text = "Justified text", + blockIndex = 20, + style = CssStyle(paragraphStyle = androidx.compose.ui.text.ParagraphStyle(textAlign = TextAlign.Justify)) + ) + ) + ).single() as ParagraphBlock + + assertEquals(TextAlign.Left, block.textAlign) + assertEquals(TextAlign.Left, block.content.paragraphStyles.first().item.textAlign) + } + @Test fun `floating image is grouped with following paragraphs until clear`() { val blocks = styler().style( @@ -136,6 +153,52 @@ class ContentStylerTest { }) } + @Test + fun `link styling is applied after nested epub span styling`() { + val label = "Nested link" + val paragraph = SemanticParagraph( + text = label, + spans = listOf( + SemanticSpan( + start = 0, + end = label.length, + style = CssStyle(), + linkHref = "https://example.org", + tag = "a" + ), + SemanticSpan( + start = 0, + end = label.length, + style = CssStyle( + spanStyle = SpanStyle( + color = Color.Red, + background = Color.Yellow, + textDecoration = TextDecoration.None + ) + ), + tag = "span" + ) + ), + style = CssStyle(), + elementId = null, + cfi = "/4/2", + blockIndex = 21 + ) + + val styled = styler().style(listOf(paragraph)).single() as ParagraphBlock + val finalCoveringStyle = styled.content.spanStyles + .filter { it.start <= 0 && it.end >= label.length } + .last() + .item + + assertEquals("https://example.org", styled.content.getStringAnnotations("URL", 0, label.length).single().item) + assertTrue(finalCoveringStyle.color.isSpecified) + assertTrue(finalCoveringStyle.color != Color.Red) + assertTrue(finalCoveringStyle.background.isSpecified) + assertTrue(finalCoveringStyle.background != Color.Yellow) + assertTrue(finalCoveringStyle.textDecoration?.contains(TextDecoration.Underline) == true) + } + @Test fun `runtime theme reapplies visible link style for cached paginated text`() { val linkText = "Cached link" @@ -166,6 +229,30 @@ class ContentStylerTest { range.item.color != Color(0xFFE0E0E0) && range.item.background.isSpecified && range.item.textDecoration?.contains(TextDecoration.Underline) == true + }) + } + + @Test + fun `block anchor from html is styled and annotated as paginated link`() { + val semanticBlocks = htmlToSemanticBlocks( + html = """

Continue reading

""", + cssRules = OptimizedCssRules(), + textStyle = TextStyle(fontSize = 16.sp, color = Color.Black), + chapterAbsPath = "OEBPS/chapter1.xhtml", + extractionBasePath = "", + density = Density(1f), + fontFamilyMap = emptyMap(), + constraints = androidx.compose.ui.unit.Constraints(maxWidth = 400, maxHeight = 800) + ) + + val paragraph = styler().style(semanticBlocks).single() as ParagraphBlock + + assertEquals("chapter2.xhtml#start", paragraph.content.getStringAnnotations("URL", 0, paragraph.content.length).single().item) + assertTrue(paragraph.content.spanStyles.any { range -> + range.start == 0 && + range.end == paragraph.content.length && + range.item.background.isSpecified && + range.item.textDecoration?.contains(TextDecoration.Underline) == true }) } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt index 2c5b05d..56c9d01 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt @@ -22,6 +22,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.nio.file.Files @OptIn(ExperimentalSerializationApi::class) class LocatorConverterTest { @@ -41,6 +43,31 @@ class LocatorConverterTest { assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator) } + @Test + fun `cfi local offsets become absolute locators and serialize back locally`() = runTest { + val converter = converterFor( + listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100)) + ) + val book = book() + + val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:7") + val cfi = locator?.let { converter.getCfiFromLocator(book, it) } + + assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator) + assertEquals("/4/2/6:7", cfi) + } + + @Test + fun `multipart cfi uses first point local offset when resolving locator`() = runTest { + val converter = converterFor( + listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100)) + ) + + val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7|/4/2/6:12") + + assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator) + } + @Test fun `zero estimate semantic cache remains usable`() = runTest { val converter = converterFor(semanticBlocks(), estimatedPageCount = 0) @@ -170,6 +197,27 @@ class LocatorConverterTest { assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2")) } + @Test + fun `large uncached chapter file is skipped instead of parsed on demand`() = runTest { + val tempDir = Files.createTempDirectory("large-locator-chapter").toFile() + try { + File(tempDir, "c1.xhtml").writeText("${"x".repeat(2_200_000)}") + val dao = FakeBookCacheDao(null) + val converter = LocatorConverter(dao, proto, mockk(relaxed = true)) + + val locator = converter.getLocatorFromCfi( + book = book(extractionBasePath = tempDir.absolutePath), + chapterIndex = 0, + cfi = "/4/2" + ) + + assertNull(locator) + assertTrue(dao.insertedChapters.isEmpty()) + } finally { + tempDir.deleteRecursively() + } + } + private fun converterFor(blocks: List, estimatedPageCount: Int = 1): LocatorConverter { val chapter = ProcessedChapter( bookId = "Book", @@ -211,7 +259,7 @@ class LocatorConverterTest { ) } - private fun book(): EpubBook { + private fun book(extractionBasePath: String = ""): EpubBook { return EpubBook( fileName = "book.epub", title = "Book", @@ -228,7 +276,7 @@ class LocatorConverterTest { htmlContent = "" ) ), - extractionBasePath = "" + extractionBasePath = extractionBasePath ) } @@ -236,12 +284,15 @@ class LocatorConverterTest { private val chapter: ProcessedChapter? ) : BookCacheDao() { val requestedBookIds = mutableListOf() + val insertedChapters = mutableListOf() - override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? { + override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int?): ProcessedChapter? { requestedBookIds += bookId return chapter } - override suspend fun insertProcessedChapters(chapters: List) = Unit + override suspend fun insertProcessedChapters(chapters: List) { + insertedChapters += chapters + } override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null override suspend fun insertProcessedBook(book: ProcessedBook) = Unit @@ -260,11 +311,13 @@ class LocatorConverterTest { override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List = emptyList() override suspend fun cleanupOldPageCaches(bookId: String) = Unit - protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null - protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List = emptyList() + protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? = null + protected override suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null + protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List = emptyList() protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit protected override suspend fun insertChapterChunks(chunks: List) = Unit protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit + protected override suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) = Unit protected override suspend fun deleteAllChapterMetadata() = Unit protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt new file mode 100644 index 0000000..47f088e --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/NativeVerticalLocationTest.kt @@ -0,0 +1,30 @@ +package com.aryan.reader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NativeVerticalLocationTest { + + @Test + fun `compat page follows native progress`() { + assertEquals(0, nativeVerticalCompatPageForProgress(0f, 101)) + assertEquals(50, nativeVerticalCompatPageForProgress(50f, 101)) + assertEquals(100, nativeVerticalCompatPageForProgress(100f, 101)) + } + + @Test + fun `progress follows compat page`() { + assertEquals(0f, nativeVerticalProgressForCompatPage(0, 101), 0.001f) + assertEquals(50f, nativeVerticalProgressForCompatPage(50, 101), 0.001f) + assertEquals(100f, nativeVerticalProgressForCompatPage(100, 101), 0.001f) + } + + @Test + fun `progress target skips zero weight chapter gaps`() { + val weights = listOf(0, 100, 300, 600) + + assertEquals(1, nativeVerticalProgressToItemIndex(weights, 0f)) + assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f)) + assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f)) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt index 89559da..96926d0 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt @@ -3,6 +3,7 @@ package com.aryan.reader.paginatedreader import androidx.compose.ui.text.AnnotatedString import com.aryan.reader.epubreader.HighlightColor import com.aryan.reader.epubreader.UserHighlight +import com.aryan.reader.shared.ReaderLocator import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -40,7 +41,7 @@ class PaginatedHighlightMappingTest { } @Test - fun `same path split block outside stored offsets is ignored`() { + fun `same path split uses cfi offsets as local to block`() { val block = paragraph( text = "repeat", cfi = "/4/2", @@ -51,9 +52,146 @@ class PaginatedHighlightMappingTest { text = "repeat" ) + 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:40|/4/2:46", + text = "repeat" + ) + assertNull(getHighlightOffsetsInBlock(block, highlight)) } + @Test + fun `desktop locator highlight maps by source offsets`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = null, + startOffset = 20 + ) + val highlight = highlight( + cfi = "desktop:0:26:30", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `locator offsets win over cfi offsets for synced highlights`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta", + locator = ReaderLocator( + chapterIndex = 0, + startOffset = 206, + endOffset = 210, + cfi = "/4/2:6|/4/2:10", + textQuote = "beta" + ) + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `locator offsets prevent cfi fallback from painting unrelated duplicate block`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/4", + startOffset = 300 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta", + locator = ReaderLocator( + chapterIndex = 0, + startOffset = 206, + endOffset = 210, + cfi = "/4/2:6|/4/2:10", + textQuote = "beta" + ) + ) + + assertNull(getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `block local locator offsets do not paint sibling blocks with overlapping local ranges`() { + val highlight = highlight( + cfi = "/4/4/6:124|/4/4/6:248", + text = "selected text", + locator = ReaderLocator( + chapterIndex = 8, + pageIndex = 49, + startOffset = 124, + endOffset = 248, + blockIndex = 1, + charOffset = 124, + textQuote = "selected text", + cfi = "/4/4/6:124|/4/4/6:248" + ) + ) + val selectedBlock = paragraph( + text = "x".repeat(260), + cfi = "/4/4/6", + startOffset = 0, + blockIndex = 1 + ) + val siblingBlock = paragraph( + text = "x".repeat(684), + cfi = "/4/4/8", + startOffset = 0, + blockIndex = 2 + ) + + assertEquals(124 until 248, getHighlightOffsetsInBlock(selectedBlock, highlight)) + assertNull(getHighlightOffsetsInBlock(siblingBlock, highlight)) + } + + @Test + fun `source cfi local offsets map within nonzero source block`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:6|/4/2:10", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + + @Test + fun `legacy absolute cfi offsets remain supported for synced highlights`() { + val block = paragraph( + text = "alpha beta gamma", + cfi = "/4/2", + startOffset = 200 + ) + val highlight = highlight( + cfi = "/4/2:206|/4/2:210", + text = "beta" + ) + + assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight)) + } + @Test fun `paginated page highlights are scoped to page chapter`() { val chapterFourHighlight = highlight( @@ -85,29 +223,36 @@ class PaginatedHighlightMappingTest { private fun paragraph( text: String, - cfi: String, - startOffset: Int + cfi: String?, + startOffset: Int, + blockIndex: Int = startOffset ): ParagraphBlock { return ParagraphBlock( content = AnnotatedString(text), cfi = cfi, startCharOffsetInSource = startOffset, endCharOffsetInSource = startOffset + text.length, - blockIndex = startOffset + blockIndex = blockIndex ) } private fun highlight( cfi: String, text: String, - chapterIndex: Int = 0 + chapterIndex: Int = 0, + locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) ): UserHighlight { return UserHighlight( id = "highlight", cfi = cfi, text = text, color = HighlightColor.YELLOW, - chapterIndex = chapterIndex + chapterIndex = chapterIndex, + locator = locator ) } } diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatorMeasurementContractTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatorMeasurementContractTest.kt new file mode 100644 index 0000000..cda31ef --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatorMeasurementContractTest.kt @@ -0,0 +1,26 @@ +package com.aryan.reader.paginatedreader + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PaginatorMeasurementContractTest { + @Test + fun measuredTextHeightForPagination_keepsLayoutHeightWhenItContainsLastLineBottom() { + val measuredHeight = measuredTextHeightForPagination( + layoutHeightPx = 120, + lastLineBottomPx = 119.2f + ) + + assertEquals(120, measuredHeight) + } + + @Test + fun measuredTextHeightForPagination_usesCeiledLastLineBottomWhenItExceedsLayoutHeight() { + val measuredHeight = measuredTextHeightForPagination( + layoutHeightPx = 120, + lastLineBottomPx = 132.1f + ) + + assertEquals(133, measuredHeight) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/ReaderLinkAnnotationTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderLinkAnnotationTest.kt new file mode 100644 index 0000000..ed11cd6 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderLinkAnnotationTest.kt @@ -0,0 +1,51 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.text.buildAnnotatedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderLinkAnnotationTest { + @Test + fun urlAnnotationAtOffsetFindsLinkInsideRange() { + val text = linkText() + + assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset(4)) + } + + @Test + fun urlAnnotationAtOffsetFindsLinkAtEndBoundary() { + val text = linkText() + + assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset("Read more".length)) + } + + @Test + fun urlAnnotationAtOffsetReturnsNullOutsideRange() { + val text = buildAnnotatedString { + append("Read more later") + addStringAnnotation("URL", "chapter2.xhtml#start", 0, "Read more".length) + } + + assertNull(text.readerUrlAnnotationAtOffset(text.length)) + } + + @Test + fun readerExternalHrefDetectsCommonExternalSchemesCaseInsensitively() { + assertTrue("HTTPS://example.com".isReaderExternalHref()) + assertTrue("//example.com/path".isReaderExternalHref()) + assertTrue("mailto:test@example.com".isReaderExternalHref()) + assertTrue("tel:+1234567890".isReaderExternalHref()) + + assertFalse("chapter2.xhtml#start".isReaderExternalHref()) + assertFalse("#footnote-1".isReaderExternalHref()) + } + + private fun linkText() = buildAnnotatedString { + val label = "Read more" + append(label) + addStringAnnotation("URL", "chapter2.xhtml#start", 0, label.length) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt new file mode 100644 index 0000000..aa45dc9 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/paginatedreader/ReaderNavigationTargetsTest.kt @@ -0,0 +1,83 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import com.aryan.reader.SearchResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderNavigationTargetsTest { + @Test + fun `search locator resolves exact occurrence offset in text block`() { + val blocks = listOf( + ParagraphBlock( + content = AnnotatedString("first target then second target"), + cfi = "/4/2", + startCharOffsetInSource = 100, + endCharOffsetInSource = 131, + blockIndex = 7 + ) + ) + val result = SearchResult( + locationInSource = 3, + locationTitle = "Chapter", + snippet = AnnotatedString("second target"), + query = "target", + occurrenceIndexInLocation = 1, + chunkIndex = 0 + ) + + assertEquals( + Locator(chapterIndex = 3, blockIndex = 7, charOffset = 125), + findLocatorForSearchResultInBlocks(result, blocks) + ) + } + + @Test + fun `anchor locator resolves string annotation offset`() { + val content = buildAnnotatedString { + append("before anchored text") + addStringAnnotation(tag = "ID", annotation = "anchor-1", start = 7, end = 15) + } + val blocks = listOf( + ParagraphBlock( + content = content, + cfi = "/4/4", + startCharOffsetInSource = 40, + endCharOffsetInSource = 60, + blockIndex = 9 + ) + ) + + assertEquals( + Locator(chapterIndex = 2, blockIndex = 9, charOffset = 47), + findLocatorForAnchorInBlocks(chapterIndex = 2, anchor = "anchor-1", blocks = blocks) + ) + } + + @Test + fun `anchor locator resolves non text block element id`() { + val blocks = listOf( + ImageBlock( + path = "images/cover.jpg", + altText = "Cover", + elementId = "cover-image", + cfi = "/4/6", + blockIndex = 11 + ) + ) + + assertEquals( + Locator(chapterIndex = 5, blockIndex = 11, charOffset = 0), + findLocatorForAnchorInBlocks(chapterIndex = 5, anchor = "cover-image", blocks = blocks) + ) + } + + @Test + fun `native vertical initial prefetch is bounded around requested chapter`() { + assertEquals( + listOf(4, 5, 2), + nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3) + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt index 3ff4c0a..385e513 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt @@ -64,6 +64,42 @@ class BookCacheDaoTest { assertArrayEquals(largePayload, large.contentBlocksProto) } + @Test + fun `processed chapters are isolated by style config hash`() = runTest { + val firstPayload = ByteArray(950 * 1024) { 1 } + val secondPayload = byteArrayOf(2, 3, 4) + + dao.insertProcessedChapters( + listOf( + ProcessedChapter( + bookId = "book", + chapterIndex = 0, + contentBlocksProto = firstPayload, + estimatedPageCount = 10, + styleConfigHash = 111 + ) + ) + ) + dao.insertProcessedChapters( + listOf( + ProcessedChapter( + bookId = "book", + chapterIndex = 0, + contentBlocksProto = secondPayload, + estimatedPageCount = 2, + styleConfigHash = 222 + ) + ) + ) + + val firstCached = dao.getProcessedChapter("book", 0, 111)!! + val secondCached = dao.getProcessedChapter("book", 0, 222)!! + assertEquals(111, firstCached.styleConfigHash) + assertEquals(222, secondCached.styleConfigHash) + assertArrayEquals(firstPayload, firstCached.contentBlocksProto) + assertArrayEquals(secondPayload, secondCached.contentBlocksProto) + } + @Test fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest { dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10)) 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 0ce9b88..cd7859a 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt @@ -50,17 +50,54 @@ class PdfReaderPreferencesTest { val context = contextWithPrefs(prefs) savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name)) - savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name)) + savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name)) 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)) assertFalse(PdfReaderTool.BRIGHTNESS.name in loadPdfHiddenTools(context)) - assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context)) + assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), loadPdfBottomTools(context)) assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2)) } + @Test + fun `toolbar restore helpers keep saveable tab switch state sanitized`() { + val restoredOrder = restorePdfToolOrderNames( + listOf( + PdfReaderTool.SEARCH.name, + "NO_SUCH_TOOL", + PdfReaderTool.TOC.name, + PdfReaderTool.SEARCH.name + ) + ) + val expectedTools = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable) + + assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), restoredOrder.take(2)) + assertEquals(expectedTools.size, restoredOrder.size) + assertEquals(expectedTools.toSet(), restoredOrder.toSet()) + assertEquals( + setOf(PdfReaderTool.PRINT.name), + sanitizePdfHiddenToolNames(listOf(PdfReaderTool.PRINT.name, "NO_SUCH_TOOL")) + ) + assertEquals( + setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), + sanitizePdfBottomToolNames(listOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name, PdfReaderTool.PRINT.name)) + ) + assertEquals( + defaultPdfBottomTools(), + loadPdfBottomTools( + contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to setOf("NO_SUCH_TOOL"))) + ) + ) + assertEquals( + emptySet(), + loadPdfBottomTools( + contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to emptySet())) + ) + ) + } + @Test fun `reader mode and enum preferences default safely when saved values are invalid`() { val prefs = InMemorySharedPreferences( diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt index 661ad78..c12a9b1 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt @@ -60,6 +60,45 @@ class PdfReaderRepositoryTest { assertNull(repository.getAnnotationFileForSync("book")) } + @Test + fun `PdfAnnotationRepository does not rewrite unchanged annotation file`() = runTest { + val context = contextWithFilesDir(tempRoot("annotation-noop")) + val repository = PdfAnnotationRepository(context) + val annotations = mapOf( + 0 to listOf( + PdfAnnotation( + type = AnnotationType.INK, + inkType = InkType.PEN, + pageIndex = 0, + points = listOf(PdfPoint(0.1f, 0.2f, 123L)), + color = Color.Blue, + strokeWidth = 0.01f + ) + ) + ) + + repository.saveAnnotations("book", annotations) + val file = requireNotNull(repository.getAnnotationFileForSync("book")) + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveAnnotations("book", annotations) + + assertEquals(previousModified, file.lastModified()) + } + + @Test + fun `PdfAnnotationRepository stores deleted annotation tombstones for sync`() = runTest { + val context = contextWithFilesDir(tempRoot("annotation-deleted")) + val repository = PdfAnnotationRepository(context) + + repository.markAnnotationsDeleted("book", listOf("old-ink"), deletedAt = 123L) + + val file = requireNotNull(repository.getDeletedAnnotationsFileForSync("book")) + assertTrue(file.readText().contains("old-ink")) + assertTrue(file.readText().contains("123")) + } + @Test fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest { val context = contextWithFilesDir(tempRoot("highlights")) @@ -86,6 +125,29 @@ class PdfReaderRepositoryTest { assertFalse(File(context.filesDir, "pdf_highlights").exists()) } + @Test + fun `PdfHighlightRepository does not rewrite unchanged highlight file`() = runTest { + val context = contextWithFilesDir(tempRoot("highlights-noop")) + val repository = PdfHighlightRepository(context) + val highlight = PdfUserHighlight( + id = "h1", + pageIndex = 2, + bounds = emptyList(), + color = PdfHighlightColor.GREEN, + text = "quote", + range = 5 to 10 + ) + + repository.saveHighlights("book", listOf(highlight)) + val file = repository.getFileForSync("book") + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveHighlights("book", listOf(highlight)) + + assertEquals(previousModified, file.lastModified()) + } + @Test fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest { val context = contextWithFilesDir(tempRoot("textboxes")) @@ -112,6 +174,30 @@ class PdfReaderRepositoryTest { assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty()) } + @Test + fun `PdfTextBoxRepository does not rewrite unchanged textbox file`() = runTest { + val context = contextWithFilesDir(tempRoot("textboxes-noop")) + val repository = PdfTextBoxRepository(context) + val box = PdfTextBox( + id = "box", + pageIndex = 0, + relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f), + text = "Text box", + color = Color.Black, + backgroundColor = Color.White, + fontSize = 16f + ) + + repository.saveTextBoxes("book", listOf(box)) + val file = repository.getFileForSync("book") + val previousModified = 1_700_000_000_000L + assertTrue(file.setLastModified(previousModified)) + + repository.saveTextBoxes("book", listOf(box)) + + assertEquals(previousModified, file.lastModified()) + } + @Test fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest { val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default"))) diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt index 9b60f9a..57e3d45 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt @@ -181,6 +181,13 @@ class PdfReaderRichTextTest { assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText()) } + @Test + fun `selection bounds normalize reversed and clamped rich text selections`() { + assertEquals(44 to 45, androidPdfRichTextSelectionBounds(45, 44, textLength = 45)) + assertEquals(0 to 5, androidPdfRichTextSelectionBounds(-3, 99, textLength = 5)) + assertEquals(null, androidPdfRichTextSelectionBounds(3, 3, textLength = 5)) + } + @Test fun `blank page insertion uses one page break when the rich text boundary is already explicit`() { val text = "Page 1${PAGE_BREAK_CHAR}Page 2" 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 e1a220d..2009edd 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt @@ -121,7 +121,7 @@ class PdfReaderSettingsAndSharedModelsTest { @Test fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() { assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size) - assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size) + assertEquals(SharedPdfHighlighterPalette.MaxColors, SharedPdfAnnotationDefaults.highlighterPalette.size) val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN) val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER) @@ -204,6 +204,17 @@ class PdfReaderSettingsAndSharedModelsTest { PdfToolbarSection.BOTTOM, defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section ) + + val customPlacementItems = buildPdfToolbarItems( + hiddenTools = emptySet(), + toolOrder = defaultPdfToolOrder(), + bottomTools = setOf(PdfReaderTool.THEME.name) + ) + assertEquals( + PdfToolbarSection.BOTTOM, + customPlacementItems.single { it.tool == PdfReaderTool.THEME }.section + ) + val expectedMoreTools = buildSet { addAll( setOf( diff --git a/app/src/test/java/com/aryan/reader/tts/TtsCacheManagerSecurityTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsCacheManagerSecurityTest.kt new file mode 100644 index 0000000..0d1f783 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/tts/TtsCacheManagerSecurityTest.kt @@ -0,0 +1,40 @@ +package com.aryan.reader.tts + +import android.content.Context +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 +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class TtsCacheManagerSecurityTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + } + + @Test + fun `book cache directory for traversal title remains inside tts cache root`() { + val manager = TtsCacheManager(context) + val root = File(context.filesDir, "TTS_Cache").canonicalFile + val cacheDir = manager.getBookCacheDir("..").canonicalFile + + assertTrue(cacheDir.path.startsWith(root.path + File.separator)) + } + + @Test + fun `clearBookCache with traversal title does not delete app files directory`() { + val sentinel = File(context.filesDir, "tts-sentinel-${System.nanoTime()}.txt") + sentinel.writeText("keep") + + TtsCacheManager(context).clearBookCache("..") + + assertTrue(sentinel.exists()) + sentinel.delete() + } +} diff --git a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt index 335112a..dcf513a 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt +++ b/app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt @@ -60,6 +60,13 @@ class TtsChunkNavigationTest { assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = -1)) } + @Test + fun `prefetch stops only when generated chunk is neither loaded nor queued`() { + assertEquals(true, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = null)) + assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = true, playlistIndex = null)) + assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = 2)) + } + @androidx.annotation.OptIn(UnstableApi::class) @Test fun `reader tts mini bar is visible only for active reader playback outside reader routes`() { @@ -90,6 +97,45 @@ class TtsChunkNavigationTest { assertEquals(16, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = false)) } + @Test + fun `reader tts overlay size exposes the other two sizes as choices`() { + assertEquals( + listOf(ReaderTtsOverlaySize.MEDIUM, ReaderTtsOverlaySize.SMALL), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.LARGE) + ) + assertEquals( + listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.SMALL), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.MEDIUM) + ) + assertEquals( + listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.MEDIUM), + readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.SMALL) + ) + } + + @Test + fun `reader tts overlay stored size defaults to large for missing or invalid values`() { + assertEquals(ReaderTtsOverlaySize.MEDIUM, resolveReaderTtsOverlaySize("MEDIUM")) + assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize(null)) + assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize("FULL")) + } + + @Test + fun `reader tts overlay only aligns small state to the trailing edge`() { + assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.LARGE), 0f) + assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.MEDIUM), 0f) + assertEquals(1f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.SMALL), 0f) + } + + @Test + fun `reader tts chunk label uses one based progress`() { + assertEquals("Chunk 1/4", formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 4)) + assertEquals("Chunk 4/4", formatReaderTtsChunkLabel(currentChunkIndex = 3, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = -1, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 4, totalChunks = 4)) + assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 0)) + } + @Test fun `stream pcm duration uses cloud tts audio format`() { assertEquals(1_000L, resolveTtsStreamPcmDurationMs(totalBytes = 44L + 48_000L)) diff --git a/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt index 14029b6..dbb4fb0 100644 --- a/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt +++ b/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt @@ -76,6 +76,14 @@ class TtsModePolicyTest { assertEquals(TtsPlaybackManager.TtsMode.BASE, mode) } + @Test + fun `native tts voice list is resolved only when required`() { + assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = false)) + assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = " ", isOfflineBuild = false)) + assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = "voice-id", isOfflineBuild = false)) + assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = true)) + } + @Test fun `offline native tts ignores saved network voice`() { val localVoice = voice("local", requiresNetwork = false) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0873355..a411702 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,9 +1,12 @@ import org.gradle.api.GradleException import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskAction @@ -11,7 +14,13 @@ import org.gradle.jvm.tasks.Jar import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.work.DisableCachingByDefault import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream plugins { alias(libs.plugins.kotlin.multiplatform) @@ -19,33 +28,6 @@ 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 @@ -67,6 +49,179 @@ abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { } } +@DisableCachingByDefault(because = "Renames package output produced by jpackage.") +abstract class RenameDesktopMsiOutputTask : DefaultTask() { + @get:Input + abstract val msiDirectoryPath: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @TaskAction + fun renameOutput() { + val msiDirectory = File(msiDirectoryPath.get()) + val outputPackageName = packageName.get() + val outputPackageVersion = packageVersion.get() + val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi") + if (!source.isFile) return + + val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.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}.") + } + } +} + +@DisableCachingByDefault(because = "Generates local desktop service config for native packages.") +abstract class GenerateDesktopCloudConfigTask : DefaultTask() { + @get:Input + abstract val configValues: MapProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> + "$key=${value.replace("\\", "\\\\").replace("\n", "")}" + } + ) + } +} + +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class VerifyDesktopNativePackagingTask : DefaultTask() { + @get:Input + abstract val supportedHost: Property + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @get:Input + abstract val missingStandardServiceConfig: ListProperty + + @TaskAction + fun verify() { + if (!supportedHost.get()) { + throw GradleException( + "Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " + + "Current host: ${hostOsId.get()} ${hostArchId.get()}." + ) + } + val missing = missingStandardServiceConfig.get() + if (missing.isNotEmpty()) { + throw GradleException( + "Standard desktop packages require account/sync service config. Missing: " + + missing.joinToString(", ") + ". " + + "Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " + + "use -PdesktopFlavor=oss for the offline build, or set " + + "-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package." + ) + } + } +} + +@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.") +abstract class StripInvalidJarSignaturesTask : DefaultTask() { + @get:Input + abstract val jarDirectoryPath: Property + + @TaskAction + fun stripSignatures() { + val jarDirectory = File(jarDirectoryPath.get()) + if (!jarDirectory.isDirectory) return + + var strippedJarCount = 0 + jarDirectory.walkTopDown() + .filter { it.isFile && it.extension.equals("jar", ignoreCase = true) } + .forEach { jar -> + val strippedEntries = stripInvalidJarSignatures(jar) + if (strippedEntries > 0) { + strippedJarCount += 1 + logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}") + } + } + + if (strippedJarCount > 0) { + logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.") + } + } + + private fun stripInvalidJarSignatures(jar: File): Int { + val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar") + var strippedEntries = 0 + + ZipFile(jar).use { source -> + ZipOutputStream(Files.newOutputStream(temp)).use { target -> + val seenEntries = mutableSetOf() + val entries = source.entries() + while (entries.hasMoreElements()) { + val sourceEntry = entries.nextElement() + val entryName = sourceEntry.name + if (!seenEntries.add(entryName)) continue + if (isJarSignatureResource(entryName)) { + strippedEntries += 1 + continue + } + + val targetEntry = ZipEntry(entryName) + if (sourceEntry.time >= 0) { + targetEntry.time = sourceEntry.time + } + target.putNextEntry(targetEntry) + if (!sourceEntry.isDirectory) { + source.getInputStream(sourceEntry).use { input -> + input.copyTo(target) + } + } + target.closeEntry() + } + } + } + + if (strippedEntries > 0) { + try { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } else { + Files.deleteIfExists(temp) + } + + return strippedEntries + } + + private fun isJarSignatureResource(entryName: String): Boolean { + val normalized = entryName.replace('\\', '/').uppercase() + if (!normalized.startsWith("META-INF/")) return false + + val metaInfName = normalized.removePrefix("META-INF/") + if (metaInfName.contains("/")) return false + + return metaInfName.startsWith("SIG-") || + metaInfName.endsWith(".SF") || + metaInfName.endsWith(".DSA") || + metaInfName.endsWith(".RSA") || + metaInfName.endsWith(".EC") + } +} + fun desktopOsId(osName: String = System.getProperty("os.name")): String { val normalized = osName.lowercase() return when { @@ -86,24 +241,27 @@ fun desktopArchId(osArch: String = System.getProperty("os.arch")): String { } } -fun desktopKcefBundleDirectoryName( +fun desktopSwtArtifactId( osName: String = System.getProperty("os.name"), osArch: String = System.getProperty("os.arch") -): String { +): 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)}" - } -} + "windows" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.win32.win32.aarch64" + else -> "org.eclipse.swt.win32.win32.x86_64" + } -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() + "linux" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.gtk.linux.aarch64" + else -> "org.eclipse.swt.gtk.linux.x86_64" + } + + "macos" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64" + else -> "org.eclipse.swt.cocoa.macosx.x86_64" + } + + else -> null } } @@ -313,25 +471,82 @@ fun normalizeDesktopPackageArchitecture(osArch: String): String { } } -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}.") +fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String { + return when (desktopOsId(osName)) { + "windows" -> "msi" + "linux" -> "deb,rpm" + "macos" -> "dmg" + else -> "" } } -val desktopVersionName = "1.0.0" +fun desktopTargetFormatForId(format: String): TargetFormat { + return when (format.lowercase()) { + "exe" -> TargetFormat.Exe + "msi" -> TargetFormat.Msi + "deb" -> TargetFormat.Deb + "rpm" -> TargetFormat.Rpm + "dmg" -> TargetFormat.Dmg + "pkg" -> TargetFormat.Pkg + else -> throw GradleException( + "Unsupported desktopPackageFormats entry '$format'. " + + "Use one or more of: msi, exe, deb, rpm, dmg, pkg." + ) + } +} + +fun desktopPackageFormatId(format: TargetFormat): String { + return when (format) { + TargetFormat.Exe -> "exe" + TargetFormat.Msi -> "msi" + TargetFormat.Deb -> "deb" + TargetFormat.Rpm -> "rpm" + TargetFormat.Dmg -> "dmg" + TargetFormat.Pkg -> "pkg" + else -> format.name.lowercase() + } +} + +fun desktopPackageFormatSupportedOnHost( + format: TargetFormat, + osName: String = System.getProperty("os.name") +): Boolean { + return when (desktopOsId(osName)) { + "windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe + "linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm + "macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg + else -> false + } +} + +fun normalizeDesktopPackageFormats( + rawFormats: String, + osName: String = System.getProperty("os.name") +): List { + val formats = rawFormats + .split(',', ';', ' ', '\n', '\t') + .map { it.trim() } + .filter { it.isNotBlank() } + .map(::desktopTargetFormatForId) + .distinct() + if (formats.isEmpty()) { + throw GradleException( + "desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " + + "Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux." + ) + } + val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) } + if (unsupported.isNotEmpty()) { + throw GradleException( + "desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " + + "the current packaging host ${desktopOsId(osName)}. Unsupported here: " + + unsupported.joinToString(",") { desktopPackageFormatId(it) } + "." + ) + } + return formats +} + +val desktopVersionName = "1.0.1" val desktopFlavor = providers.gradleProperty("desktopFlavor") .orElse("standard") .map(::normalizeDesktopFlavor) @@ -359,7 +574,21 @@ val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") val desktopOsName = System.getProperty("os.name") val desktopOsArch = System.getProperty("os.arch") val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) +val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats") + .orElse(desktopDefaultPackageFormats(desktopOsName)) + .map { normalizeDesktopPackageFormats(it, desktopOsName) } + .get() +val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") && + desktopArchId(desktopOsArch) == "x64" +val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopSwtVersion = "3.133.0" +val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch) + ?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" } val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources") +val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties") val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources") val rootLocalProperties = Properties() val rootLocalPropertiesFile = rootProject.file("local.properties") @@ -375,60 +604,26 @@ fun desktopConfigValue(vararg keys: String): String { } val desktopCloudConfig = mapOf( "AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"), - "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL", "AI_WORKER_URL"), + "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"), "FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), "FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, "GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), "GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") ) -val bundledWebViewDir = layout.projectDirectory.dir(desktopKcefBundleDirectoryName(desktopOsName, desktopOsArch)) +val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) { + emptyList() +} else { + listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID") + .filter { key -> desktopCloudConfig[key].isNullOrBlank() } +} 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) { @@ -455,49 +650,23 @@ val desktopPackagingJavaHome = findDesktopPackagingJavaHome( 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 generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) { + configValues.set(desktopCloudConfig) + outputFile.set(generatedDesktopCloudConfigFile) +} + 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") - } + dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig) from(bundledPdfiumDir) { into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}") } into("common") { - from( - providers.provider { - temporaryDir.resolve("desktop-cloud.properties").also { file -> - file.parentFile.mkdirs() - file.writeText( - desktopCloudConfig.entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> - "$key=${value.replace("\\", "\\\\").replace("\n", "")}" - } - ) - } - } - ) + from(generatedDesktopCloudConfigFile) } into(generatedDesktopResourcesDir) } @@ -512,6 +681,13 @@ val prepareDesktopStringResources by tasks.registering(Sync::class) { into(generatedDesktopStringResourcesDir) } +val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) { + supportedHost.set(desktopNativePackageSupportedHost) + hostOsId.set(desktopOsId(desktopOsName)) + hostArchId.set(desktopArchId(desktopOsArch)) + missingStandardServiceConfig.set(desktopMissingStandardServiceConfig) +} + kotlin { jvm("desktop") jvmToolchain(21) @@ -523,12 +699,14 @@ kotlin { implementation(project(":shared")) implementation(compose.desktop.currentOs) implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3") + desktopSwtDependency?.let { dependency -> + compileOnly(dependency) + runtimeOnly(dependency) + } + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") 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") } @@ -554,6 +732,10 @@ compose.desktop { jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") buildTypes.release.proguard { + // ProGuard still rewrites and shrinks release jars even when optimization and + // obfuscation are disabled. That has produced invalid stack-map frames in large + // Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs. + isEnabled.set(desktopReleaseProguardEnabled) 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. @@ -562,8 +744,17 @@ compose.desktop { } nativeDistributions { - targetFormats(TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - modules("java.net.http") + targetFormats(*desktopPackageTargetFormats.toTypedArray()) + modules( + "java.datatransfer", + "java.desktop", + "java.logging", + "java.management", + "java.net.http", + "jdk.charsets", + "jdk.httpserver", + "jdk.unsupported" + ) packageName = desktopPackageName packageVersion = desktopPackageVersion.get() description = desktopPackageDescription @@ -612,19 +803,41 @@ tasks.withType().configureEach { } } +val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) { + tasks.registering(StripInvalidJarSignaturesTask::class) { + dependsOn("proguardReleaseJars") + jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath }) + } +} else { + null +} + +tasks.matching { + it.name in setOf( + "createReleaseDistributable", + "packageReleaseDistributionForCurrentOS", + "packageReleaseExe", + "packageReleaseMsi", + "packageReleaseDeb", + "packageReleaseRpm", + "runReleaseDistributable" + ) +}.configureEach { + stripReleaseProguardJarSignatures?.let { dependsOn(it) } +} + mapOf( "packageMsi" to "main", "packageReleaseMsi" to "main-release" ).forEach { (taskName, distributionName) -> + val renameTask = tasks.register("rename${taskName.replaceFirstChar(Char::titlecase)}Output") { + msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath) + packageName.set(desktopPackageName) + packageVersion.set(desktopPackageVersion.get()) + architecture.set(desktopPackageArchitecture) + } 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 - ) - } + finalizedBy(renameTask) } } @@ -648,6 +861,7 @@ tasks.matching { "runReleaseDistributable" ) }.configureEach { + dependsOn(verifyDesktopNativePackaging) dependsOn(prepareBundledDesktopResources) inputs.dir(generatedDesktopResourcesDir) .withPropertyName("bundledDesktopResources") diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro index bfec64c..d3805a8 100644 --- a/desktopApp/compose-desktop.pro +++ b/desktopApp/compose-desktop.pro @@ -1,5 +1,3 @@ --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.** { *; } @@ -7,17 +5,13 @@ -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.** +# Desktop release shrinking sees optional integrations from JOGL, Commons Compress +# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers. -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.** diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt index 03e4324..25019b5 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAccountProfileRepository.kt @@ -8,18 +8,49 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.io.File import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder +import java.util.Properties internal data class DesktopAccountProfile( val isProUser: Boolean = false, - val credits: Int = 0 + val credits: Int = 0, + val fetchedAtEpochMillis: Long = 0L ) +// Credits and Pro status are server-owned, so startup only trusts a recent snapshot. +internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L + +internal fun DesktopAccountProfile.isFresh( + nowEpochMillis: Long = System.currentTimeMillis(), + ttlMillis: Long = DesktopAccountProfileCacheTtlMillis +): Boolean { + if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false + val ageMillis = nowEpochMillis - fetchedAtEpochMillis + return ageMillis in 0L..ttlMillis +} + internal class DesktopAccountProfileRepository( - private val config: DesktopCloudConfig + private val config: DesktopCloudConfig, + private val store: DesktopAccountProfileStore = DesktopAccountProfileStore() ) { + fun cachedProfile( + uid: String, + nowEpochMillis: Long = System.currentTimeMillis() + ): DesktopAccountProfile? { + return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) } + } + + fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) { + store.save(uid, profile) + } + + fun clearCachedProfiles() { + store.clear() + } + suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) { if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile() val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}" @@ -31,23 +62,60 @@ internal class DesktopAccountProfileRepository( readTimeout = 20_000 } try { - if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) return@withContext DesktopAccountProfile() + if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis()) + } val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() if (connection.responseCode !in 200..299) { throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}") } val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull() - DesktopAccountProfile( + val profile = DesktopAccountProfile( isProUser = fields?.booleanField("isPro") == true, - credits = fields?.numberField("credits")?.toInt() ?: 0 + credits = fields?.numberField("credits")?.toInt() ?: 0, + fetchedAtEpochMillis = System.currentTimeMillis() ) + profile } finally { connection.disconnect() } } } +internal class DesktopAccountProfileStore( + private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties") +) { + fun load(uid: String): DesktopAccountProfile? { + if (uid.isBlank() || !settingsFile.isFile) return null + val properties = Properties() + return runCatching { + settingsFile.inputStream().use(properties::load) + if (properties.getProperty("uid", "") != uid) return null + DesktopAccountProfile( + isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false, + credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0, + fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L + ) + }.getOrNull() + } + + fun save(uid: String, profile: DesktopAccountProfile) { + if (uid.isBlank()) return + val properties = Properties().apply { + setProperty("uid", uid) + setProperty("isProUser", profile.isProUser.toString()) + setProperty("credits", profile.credits.toString()) + setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString()) + } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile") + } + + fun clear() { + settingsFile.delete() + } +} + private val DesktopAccountJson = Json { ignoreUnknownKeys = true } private fun JsonObject?.booleanField(key: String): Boolean? { 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 a2905e6..1b33f50 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt @@ -13,6 +13,7 @@ import com.sun.jna.win32.StdCallLibrary import java.io.File import java.util.Base64 import java.util.Properties +import java.util.concurrent.TimeUnit private const val WINDOWS_CRED_TYPE_GENERIC = 1 private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2 @@ -60,7 +61,7 @@ internal class DesktopAiByokStore( loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID) } else { loadedSettings - } + }.toDesktopPersistableAiSettings() if (secureStorageAvailable && (legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings) ) { @@ -82,7 +83,7 @@ internal class DesktopAiByokStore( } fun save(settings: ReaderAiByokSettings) { - val sanitized = settings.sanitized() + val sanitized = settings.toDesktopPersistableAiSettings() logDesktopTts( "settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + "secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " + @@ -101,9 +102,7 @@ internal class DesktopAiByokStore( setProperty("ttsSpeakerId", sanitized.ttsSpeakerId) } settingsFile.parentFile?.mkdirs() - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop AI keys and models") - } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models") logDesktopTts( "settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " + "groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}" @@ -173,10 +172,10 @@ internal interface DesktopSecretCodec { companion object { fun platform(): DesktopSecretCodec { val osName = System.getProperty("os.name").orEmpty() - val codec = if (osName.startsWith("Windows", ignoreCase = true)) { - WindowsSecretCodec - } else { - UnavailableDesktopSecretCodec + val codec = when { + osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec + osName.contains("Linux", ignoreCase = true) -> LinuxSecretToolCodec() + else -> UnavailableDesktopSecretCodec } logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}") return codec @@ -193,6 +192,160 @@ private object UnavailableDesktopSecretCodec : DesktopSecretCodec { override fun unprotect(value: String): String = "" } +internal data class DesktopSecretCommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String +) { + val isSuccess: Boolean get() = exitCode == 0 + val errorSummary: String + get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" } +} + +internal interface DesktopSecretCommandRunner { + fun isExecutableAvailable(command: String): Boolean + fun run(command: List, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult +} + +private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner { + override fun isExecutableAvailable(command: String): Boolean { + val path = System.getenv("PATH").orEmpty() + return path.split(File.pathSeparator) + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .any { directory -> + File(directory, command).let { it.isFile && it.canExecute() } + } + } + + override fun run(command: List, input: String?, timeoutMillis: Long): DesktopSecretCommandResult { + require(command.isNotEmpty()) { "Secret command cannot be empty." } + val process = ProcessBuilder(command).start() + input?.let { value -> + process.outputStream.use { output -> + output.write(value.toByteArray(Charsets.UTF_8)) + } + } ?: process.outputStream.close() + + val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS) + if (!completed) { + process.destroyForcibly() + throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.") + } + return DesktopSecretCommandResult( + exitCode = process.exitValue(), + stdout = process.inputStream.readBytes().toString(Charsets.UTF_8), + stderr = process.errorStream.readBytes().toString(Charsets.UTF_8) + ) + } +} + +internal class LinuxSecretToolCodec( + private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner +) : DesktopSecretCodec { + override val name: String = "linux-secret-tool" + + override val isAvailable: Boolean by lazy { + val available = commandRunner.isExecutableAvailable(SecretToolCommand) && + runCatching { + commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess + }.getOrDefault(false) + logDesktopTts("settings_linux_secret_tool_available available=$available") + available + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + if (!isAvailable) { + throw IllegalStateException( + "Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running." + ) + } + val key = linuxSecretKey(keyName) + logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "store", + "--label", + "Episteme $keyName", + SecretToolApplicationAttribute, + SecretToolApplicationValue, + SecretToolKeyAttribute, + key + ), + input = value, + timeoutMillis = 15_000L + ) + logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}") + } + return Prefix + key + } + + override fun unprotect(keyName: String, value: String): String { + if (!isAvailable) return "" + val key = value.removePrefix(Prefix).takeIf { value.startsWith(Prefix) } ?: linuxSecretKey(keyName) + logDesktopTts("settings_linux_secret_tool_read_start key=$keyName") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "lookup", + SecretToolApplicationAttribute, + SecretToolApplicationValue, + SecretToolKeyAttribute, + key + ), + timeoutMillis = 8_000L + ) + logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}") + } + return result.stdout.trimEnd('\r', '\n') + } + + override fun delete(keyName: String) { + val key = linuxSecretKey(keyName) + runCatching { + commandRunner.run( + command = listOf( + SecretToolCommand, + "clear", + SecretToolApplicationAttribute, + SecretToolApplicationValue, + SecretToolKeyAttribute, + key + ), + timeoutMillis = 8_000L + ) + }.onFailure { error -> + logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") + } + } + + private fun linuxSecretKey(keyName: String): String { + return "Episteme.Reader.$keyName" + } + + private companion object { + const val Prefix = "secret-tool:" + const val SecretToolCommand = "secret-tool" + const val SecretToolApplicationAttribute = "application" + const val SecretToolApplicationValue = "Episteme.Reader" + const val SecretToolKeyAttribute = "key" + } +} + private object WindowsSecretCodec : DesktopSecretCodec { override val name: String = "windows" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt index 80acd9c..29debfe 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiHub.kt @@ -14,11 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.VolumeUp -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -42,13 +38,8 @@ import androidx.compose.ui.text.AnnotatedString 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.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderCloudTtsVoices -import com.aryan.reader.shared.ReaderTtsCacheSummary import com.aryan.reader.shared.RecapResult import com.aryan.reader.shared.SummarizationResult -import com.aryan.reader.shared.readerCloudTtsVoiceById import com.aryan.reader.shared.ui.SharedMarkdownText import com.aryan.reader.shared.ui.readerString @@ -345,169 +336,3 @@ private fun DesktopSummaryCachePanel( } } } - -@Composable -internal fun DesktopCloudTtsChromeControls( - settings: ReaderAiByokSettings, - cloudTts: ReaderCloudTtsState, - credits: Int, - showCredits: Boolean, - onRead: () -> Unit, - onPauseResume: () -> Unit, - onStop: () -> Unit, - onOpenSettings: () -> Unit -) { - val sanitized = settings.sanitized() - val voice = readerCloudTtsVoiceById(sanitized.ttsSpeakerId) - val ttsBusy = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(8.dp), - tonalElevation = 1.dp - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.VolumeUp, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Column(modifier = Modifier.weight(1f)) { - Text( - when { - cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio") - cloudTts.isPaused -> readerString("desktop_paused", "Paused") - cloudTts.isPlaying -> readerString("label_reading", "Reading") - sanitized.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready") - else -> readerString("desktop_cloud_tts_unavailable", "Cloud TTS unavailable") - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - cloudTts.errorMessage - ?: cloudTts.progress.currentPositionLabel - ?: cloudTts.statusMessage - ?: voice?.let { "${it.name}: ${it.description}" } - ?: "", - style = MaterialTheme.typography.labelSmall, - color = if (cloudTts.errorMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (showCredits) { - AssistChip(onClick = {}, label = { Text(readerString("credits_count", "%1\$d credits", credits)) }) - } - if (cloudTts.isPlaying || cloudTts.isPaused) { - TextButton(onClick = onPauseResume) { - Text(if (cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause")) - } - } - TextButton( - enabled = sanitized.isCloudTtsAvailable || ttsBusy, - onClick = { if (ttsBusy) onStop() else onRead() } - ) { - Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read")) - } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = readerString("desktop_cloud_tts_settings", "Cloud TTS settings")) - } - } - } -} - -@Composable -internal fun DesktopCloudTtsSettingsOverlay( - settings: ReaderAiByokSettings, - isTtsActive: Boolean, - showCredits: Boolean, - credits: Int, - cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary(), - onClearCache: (() -> Unit)? = null, - onSettingsChange: (ReaderAiByokSettings) -> Unit -) { - val sanitized = settings.sanitized() - Surface( - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(8.dp), - tonalElevation = 4.dp, - shadowElevation = 8.dp - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Text( - if (isTtsActive) { - readerString("desktop_stop_reading_change_voices", "Stop reading to change voices.") - } else { - readerString("desktop_choose_cloud_tts_voice", "Choose the Gemini voice used for cloud read aloud.") - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (showCredits) { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(10.dp) - ) { - Text( - readerString("credits_count", "%1\$d credits", credits), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } - } - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - ReaderCloudTtsVoices.forEach { voice -> - FilterChip( - selected = sanitized.ttsSpeakerId == voice.id, - enabled = !isTtsActive, - onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, - label = { - Column { - Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - voice.description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - ) - } - } - if (cacheSummary.hasCachedAudio) { - HorizontalDivider() - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("desktop_voice_cache", "Voice cache"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Text( - cacheSummary.currentVoiceLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (cacheSummary.hasCurrentVoiceCachedAudio && onClearCache != null) { - TextButton(enabled = !isTtsActive, onClick = onClearCache) { - Text(readerString("desktop_clear_voice_cache", "Clear voice cache")) - } - } - } - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt index e154fe0..babe5ae 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.aryan.reader.shared.AppContrastOption import com.aryan.reader.shared.AppThemeMode -import com.aryan.reader.shared.ReaderFeatureSurface import com.aryan.reader.shared.ui.SharedAppTheme import com.aryan.reader.shared.ui.readerString import kotlinx.coroutines.Dispatchers @@ -58,6 +57,8 @@ import java.util.concurrent.atomic.AtomicReference internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300) +private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L) + internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { configureComposeSwingInterop() application { @@ -190,35 +191,69 @@ internal const val ComposeInteropBlendingProperty = "compose.interop.blending" internal const val ComposeInteropBlendingEnabled = "true" private const val DesktopWindowStatePersistDebounceMillis = 450L -internal fun configureComposeSwingInterop() { - // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed - // JCEF WebView, and current Compose interop can leave a stale black native rectangle after - // that reader surface is removed unless interop blending is enabled. - if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { - System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled) +internal fun composeInteropBlendingDefault( + platform: DesktopPlatform = currentDesktopPlatform() +): String? { + return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + null + } else { + ComposeInteropBlendingEnabled } } +internal fun configureComposeSwingInterop( + platform: DesktopPlatform = currentDesktopPlatform() +) { + // Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT + // browser surfaces; the blending path can prevent those native children from painting. + if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { + composeInteropBlendingDefault(platform)?.let { defaultValue -> + System.setProperty(ComposeInteropBlendingProperty, defaultValue) + } + } + logDesktopWebView2( + "compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}" + ) +} + @Composable -private fun DesktopWindowStatePersistenceEffect( +internal fun DesktopWindowStatePersistenceEffect( windowState: WindowState, store: DesktopWindowStateStore, - enabled: Boolean + enabled: Boolean, + transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it }, + onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {} ) { val persistenceEnabled by rememberUpdatedState(enabled) + val latestTransformSnapshot by rememberUpdatedState(transformSnapshot) + val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved) LaunchedEffect(windowState, store) { snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } .distinctUntilChanged() .collectLatest { snapshot -> if (!persistenceEnabled || snapshot == null) return@collectLatest + val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest delay(DesktopWindowStatePersistDebounceMillis) if (persistenceEnabled) { withContext(Dispatchers.IO) { - store.save(snapshot) + store.save(persistableSnapshot) } + latestOnSnapshotSaved(persistableSnapshot) } } } + DisposableEffect(windowState, store) { + onDispose { + if (persistenceEnabled) { + DesktopWindowStateSnapshot.fromWindowState(windowState) + ?.let(latestTransformSnapshot) + ?.let { snapshot -> + runCatching { store.save(snapshot) } + latestOnSnapshotSaved(snapshot) + } + } + } + } } @Composable @@ -259,6 +294,14 @@ internal fun DesktopReaderFullscreenEffect( } awtWindow.refreshDesktopReaderWindowFocus() } + if (!enabled) { + for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) { + delay(delayMillis) + EventQueue.invokeLater { + awtWindow.refreshDesktopReaderWindowFocus() + } + } + } } DisposableEffect(awtWindow) { @@ -444,16 +487,43 @@ private fun java.awt.Window.refreshDesktopReaderWindowFocus() { internal fun DesktopReaderFullscreenKeyEffect( enabled: Boolean, onKeyPressed: (AwtKeyEvent) -> Boolean +) { + DesktopReaderKeyDispatcherEffect( + enabled = enabled, + allowChromeModalWindows = false, + onKeyPressed = onKeyPressed + ) +} + +@Composable +internal fun DesktopReaderKeyDispatcherEffect( + enabled: Boolean, + allowChromeModalWindows: Boolean = false, + allowPanelModalWindows: Boolean = false, + dispatchWhenOwnerWindowActive: Boolean = true, + onKeyPressed: (AwtKeyEvent) -> Boolean ) { val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) - DisposableEffect(enabled) { + DisposableEffect( + enabled, + allowChromeModalWindows, + allowPanelModalWindows, + dispatchWhenOwnerWindowActive + ) { if (!enabled) { onDispose {} } else { val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() val dispatcher = java.awt.KeyEventDispatcher { event -> - val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true - !modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) + val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow + val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind() + val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = activeReaderModalKind, + allowChromeModalWindows = allowChromeModalWindows, + allowPanelModalWindows = allowPanelModalWindows, + dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive + ) + activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) } focusManager.addKeyEventDispatcher(dispatcher) onDispose { @@ -463,18 +533,62 @@ internal fun DesktopReaderFullscreenKeyEffect( } } -private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean { +internal enum class DesktopReaderModalWindowKind { + CHROME, + PANEL, + POPUP +} + +internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind: DesktopReaderModalWindowKind?, + allowChromeModalWindows: Boolean, + allowPanelModalWindows: Boolean, + dispatchWhenOwnerWindowActive: Boolean +): Boolean { + return when (activeReaderModalKind) { + null -> dispatchWhenOwnerWindowActive + DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows + DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows + DesktopReaderModalWindowKind.POPUP -> false + } +} + +private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? { 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") + return desktopReaderModalWindowKind( + windowName = name.orEmpty(), + windowTitle = windowTitle + ) } -private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" +internal fun desktopReaderModalWindowKind( + windowName: String, + windowTitle: String +): DesktopReaderModalWindowKind? { + return when { + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" || + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" || + windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME + + windowName == "${DesktopReaderModalWindowNamePrefix}Panel" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Navigation") || + windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL + + windowName.startsWith(DesktopReaderModalWindowNamePrefix) || + windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP + + else -> null + } +} + +internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" internal data class DesktopWebViewRuntimeState( val initialized: Boolean = false, @@ -483,15 +597,43 @@ internal data class DesktopWebViewRuntimeState( val errorMessage: String? = null ) -internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean { - return readerSurface == ReaderFeatureSurface.TEXT_READER +internal enum class DesktopEpubWebViewBackend( + val logName: String, + val displayName: String +) { + WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"), + WEBKIT("webkit", "WebKit"), + UNSUPPORTED("unsupported", "native webview") } -internal fun shouldStartDesktopWebViewRuntime( - requested: Boolean, - state: DesktopWebViewRuntimeState +internal fun desktopEpubWebViewBackend( + platform: DesktopPlatform = currentDesktopPlatform() +): DesktopEpubWebViewBackend { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 + DesktopOperatingSystem.LINUX, + DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT + DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED + } +} + +internal fun desktopEpubWebViewUsesNativeSwtBrowser( + platform: DesktopPlatform = currentDesktopPlatform() ): Boolean { - return requested && !state.initialized && !state.restartRequired && state.errorMessage == null + return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED +} + +internal fun desktopEpubWebViewUsesWebView2( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 +} + +internal fun desktopEpubWebViewCanRender( + state: DesktopWebViewRuntimeState, + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewUsesNativeSwtBrowser(platform) } @Composable @@ -499,7 +641,10 @@ internal fun DesktopWebViewRuntimeIndicator( state: DesktopWebViewRuntimeState, modifier: Modifier = Modifier ) { + val platform = currentDesktopPlatform() val message = when { + !desktopEpubWebViewUsesNativeSwtBrowser(platform) -> + readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.") state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage) state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.") state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt()) @@ -515,7 +660,9 @@ internal fun DesktopWebViewRuntimeIndicator( verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (state.errorMessage == null && !state.restartRequired) { - CircularProgressIndicator() + if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + CircularProgressIndicator() + } } Text( text = message, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt index f8f8765..485e86e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppState.kt @@ -7,8 +7,12 @@ import com.aryan.reader.shared.SharedLibrarySnapshot import com.aryan.reader.shared.SharedLibraryStateProjector import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.ShelfRecord +import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.SharedEpubBook import com.aryan.reader.shared.reader.SharedEpubChapter +import com.aryan.reader.shared.ui.SharedAppTab + +internal val DesktopInitialAppTab = SharedAppTab.LIBRARY internal fun desktopEmptyReaderBook(): SharedEpubBook { val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open") @@ -27,11 +31,37 @@ internal fun desktopEmptyReaderBook(): SharedEpubBook { } internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { - return if (appSeedColor == null) { - copy(appSeedColor = DesktopDefaultAppSeedColor) + val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion + val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) { + DesktopDefaultTextReaderSettings } else { - this + readerDefaultSettings } + val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) { + DesktopDefaultPdfReaderSettings + } else { + pdfReaderDefaultSettings + } + val migratedBooks = if (shouldMigrateReaderDefaults) { + books.map { book -> + when { + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) && + book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults) + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) && + book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults) + else -> book + } + } + } else { + books + } + return copy( + books = migratedBooks, + appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor, + readerDefaultSettings = migratedTextDefaults, + pdfReaderDefaultSettings = migratedPdfDefaults, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion + ) } internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState { @@ -54,6 +84,7 @@ internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScr appSeedColor = appSeedColor, appFontPreference = appFontPreference, customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, readerDefaultSettings = readerDefaultSettings, pdfReaderDefaultSettings = pdfReaderDefaultSettings, readerToolbarPreferences = readerToolbarPreferences, @@ -116,8 +147,10 @@ internal fun SharedReaderScreenState.toDesktopLibrarySnapshot( appSeedColor = appSeedColor, appFontPreference = appFontPreference, customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, readerDefaultSettings = readerDefaultSettings, pdfReaderDefaultSettings = pdfReaderDefaultSettings, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion, readerToolbarPreferences = readerToolbarPreferences, readerHighlightPalette = readerHighlightPalette, pdfHighlighterPalette = pdfHighlighterPalette, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAtomicFile.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAtomicFile.kt new file mode 100644 index 0000000..930308d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAtomicFile.kt @@ -0,0 +1,56 @@ +package com.aryan.reader.desktop + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Properties + +internal fun File.writeTextAtomically(text: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.writeText(text) + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +internal fun File.storePropertiesAtomically(properties: Properties, comments: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.outputStream().use { output -> + properties.store(output, comments) + } + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +private fun File.createSiblingTempFile(): File { + val directory = parentFile ?: File(".") + directory.mkdirs() + val prefix = ".$name." + return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile() +} + +private fun moveReplacing(source: File, target: File) { + target.parentFile?.mkdirs() + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt index c0e5d4e..c7a8681 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopBuildProfile.kt @@ -2,7 +2,9 @@ package com.aryan.reader.desktop import com.aryan.reader.shared.ReaderAiByokSettings import com.aryan.reader.shared.SharedFeaturePolicy -import java.io.File +import com.aryan.reader.shared.SharedLegalLinks +import com.aryan.reader.shared.SharedLegalProfile +import com.aryan.reader.shared.sharedLegalLinksForProfile internal const val DesktopFlavorProperty = "episteme.desktop.flavor" internal const val DesktopVersionProperty = "episteme.desktop.version" @@ -16,10 +18,21 @@ internal data class DesktopBuildProfile( val flavor: String, val appName: String, val buildLabel: String, - val featurePolicy: SharedFeaturePolicy + val featurePolicy: SharedFeaturePolicy, + val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) { + SharedLegalProfile.OSS + } else { + SharedLegalProfile.STANDARD + } ) { val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline + val aiKeySettingsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess + val creditBackedCloudTtsControlsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable + val legalLinks: SharedLegalLinks + get() = sharedLegalLinksForProfile(legalProfile) } internal fun currentDesktopBuildProfile(): DesktopBuildProfile { @@ -35,13 +48,15 @@ internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfi flavor = DesktopFlavorOssOffline, appName = EpistemeDesktopOssAppName, buildLabel = "Offline OSS edition", - featurePolicy = SharedFeaturePolicy.OssOffline + featurePolicy = SharedFeaturePolicy.OssOffline, + legalProfile = SharedLegalProfile.OSS ) else -> DesktopBuildProfile( flavor = DesktopFlavorStandard, appName = EpistemeDesktopStandardAppName, buildLabel = "Standard edition", - featurePolicy = SharedFeaturePolicy.Standard + featurePolicy = SharedFeaturePolicy.Standard, + legalProfile = SharedLegalProfile.STANDARD ) } } @@ -59,48 +74,12 @@ internal fun ReaderAiByokSettings.withDesktopFeaturePolicy( featurePolicy: SharedFeaturePolicy ): ReaderAiByokSettings { return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) { - sanitized() + toDesktopPersistableAiSettings() } else { - ReaderAiByokSettings(hideReaderAiFeatures = true) + ReaderAiByokSettings() } } -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() - } +internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings { + return sanitized().copy(hideReaderAiFeatures = false) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt index 75a9a6a..1a93985 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudConfig.kt @@ -21,7 +21,14 @@ internal data class DesktopCloudConfig( } internal fun loadDesktopCloudConfig(): DesktopCloudConfig { - val resourceProperties = Properties().apply { + return desktopCloudConfigFromProperties( + resourceProperties = loadDesktopCloudResourceProperties(), + localProperties = loadDesktopLocalProperties() + ) +} + +private fun loadDesktopCloudResourceProperties(): Properties { + return Properties().apply { val classLoader = DesktopCloudConfig::class.java.classLoader val stream = classLoader.getResourceAsStream("desktop-cloud.properties") ?: classLoader.getResourceAsStream("common/desktop-cloud.properties") @@ -35,18 +42,27 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig { } stream?.use { input -> load(input) } } - val localProperties = Properties().apply { - File("local.properties") - .takeIf { it.isFile } +} + +private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties { + return Properties().apply { + file.takeIf { it.isFile } ?.inputStream() ?.use { input -> load(input) } } +} +internal fun desktopCloudConfigFromProperties( + resourceProperties: Properties, + localProperties: Properties = Properties(), + systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") }, + environment: (String) -> String? = { key -> System.getenv(key) } +): DesktopCloudConfig { fun value(vararg keys: String): String { return keys.firstNotNullOfOrNull { key -> - System.getProperty("episteme.desktop.$key") - ?: System.getenv("EPISTEME_DESKTOP_${key.uppercase()}") - ?: System.getenv(key) + systemProperty(key) + ?: environment("EPISTEME_DESKTOP_${key.uppercase()}") + ?: environment(key) ?: localProperties.getProperty("DESKTOP_$key") ?: localProperties.getProperty(key) ?: resourceProperties.getProperty(key) @@ -56,7 +72,7 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig { val aiWorkerUrl = value("AI_WORKER_URL").ifBlank { "https://reader-ai.aryanrajttps.workers.dev" } - val ttsWorkerUrl = value("TTS_WORKER_URL").ifBlank { aiWorkerUrl } + val ttsWorkerUrl = value("TTS_WORKER_URL") return DesktopCloudConfig( aiWorkerUrl = aiWorkerUrl, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt index bba804e..82c818f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudRepositories.kt @@ -32,6 +32,7 @@ import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.StandardCopyOption import java.time.Duration +import java.time.Instant import java.util.Collections import java.util.UUID @@ -50,6 +51,8 @@ internal data class DesktopCloudBookMetadata( val isRecent: Boolean = true, val isDeleted: Boolean = false, val lastModifiedTimestamp: Long = 0L, + val readingPositionModifiedTimestamp: Long = 0L, + val annotationModifiedTimestamp: Long = 0L, val bookmarksJson: String? = null, val hasAnnotations: Boolean = false, val fileContentModifiedTimestamp: Long = 0L, @@ -83,7 +86,8 @@ internal data class DesktopCloudFontMetadata( internal data class DesktopDriveFile( val id: String, - val name: String + val name: String, + val modifiedTimeMillis: Long = 0L ) internal class DesktopFirestoreRepository( @@ -266,6 +270,10 @@ internal class DesktopGoogleDriveRepository( listFiles(accessToken = accessToken, query = null) } + suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) { + listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull() + } + suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? = uploadNamedFile( accessToken = accessToken, @@ -293,18 +301,20 @@ internal class DesktopGoogleDriveRepository( suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? { return uploadNamedFile( accessToken = accessToken, - fileName = "annotation_$bookId.json", + fileName = desktopCloudAnnotationDriveFileName(bookId), file = file, contentType = "application/json" ) } suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean { - val fileId = listFiles(accessToken, "name = '${driveQueryStringValue("annotation_$bookId.json")}' and trashed = false") - .firstOrNull() - ?.id + val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId)) ?: return false - return downloadFile(accessToken, fileId, destination) + return downloadFile(accessToken, driveFile.id, destination).also { downloaded -> + if (downloaded && driveFile.modifiedTimeMillis > 0L) { + destination.setLastModified(driveFile.modifiedTimeMillis) + } + } } suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) { @@ -375,9 +385,9 @@ internal class DesktopGoogleDriveRepository( }.toByteArray(Charsets.UTF_8) val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8) val uploadUri = if (existingFileId == null) { - URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name")}") + URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") } else { - URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name")}") + URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") } val request = HttpRequest.newBuilder(uploadUri) .timeout(Duration.ofMinutes(5)) @@ -399,14 +409,15 @@ internal class DesktopGoogleDriveRepository( val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject DesktopDriveFile( id = root.string("id").orEmpty(), - name = root.string("name").orEmpty() + name = root.string("name").orEmpty(), + modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime")) ) } private fun listFiles(accessToken: String, query: String?): List { val params = buildList { add("spaces" to "appDataFolder") - add("fields" to "files(id,name)") + add("fields" to "files(id,name,modifiedTime)") if (!query.isNullOrBlank()) add("q" to query) } val request = HttpRequest.newBuilder( @@ -426,11 +437,20 @@ internal class DesktopGoogleDriveRepository( val obj = element.jsonObjectOrNull() ?: return@mapNotNull null val id = obj.string("id") ?: return@mapNotNull null val name = obj.string("name") ?: return@mapNotNull null - DesktopDriveFile(id = id, name = name) + DesktopDriveFile( + id = id, + name = name, + modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime")) + ) } } } +private fun parseDriveModifiedTimeMillis(value: String?): Long { + if (value.isNullOrBlank()) return 0L + return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L) +} + private data class DesktopFirestoreDocument( val id: String, val fields: JsonObject? @@ -464,6 +484,8 @@ private fun DesktopCloudBookMetadata.toFirestoreFields(): Map) { + val path = book.path?.takeIf { it.isNotBlank() } ?: return + if (book.type != FileType.PDF) return + recordAnnotationDeletions(path, book.id, annotationIds) + } + + fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection) { + val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() + if (ids.isEmpty()) return + val file = desktopPdfAnnotationDeletionFile(documentPath) + val existing = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + val now = System.currentTimeMillis() + val next = existing.toMutableMap() + ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) } + val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) + if (file.isFile && file.readText() == nextJson) return + file.parentFile?.mkdirs() + file.writeText(nextJson) + logDesktopCloudAnnotations { + "desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + fun exportAnnotationBundle(book: BookItem): File? { val path = book.path?.takeIf { it.isNotBlank() } ?: return null if (book.type != FileType.PDF) return null val annotationFile = desktopPdfAnnotationFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}" + } val data = buildMap { if (annotationFile.isFile) { - val annotations = SharedPdfAnnotationSerializer.decode(annotationFile.readText()) - put( - SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, - SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) - ) - } - if (bookmarkFile.isFile) { - cloudSidecarJson.parseElementOrNull(bookmarkFile.readText())?.let { put("bookmarks", it) } - } - if (richTextFile.isFile) { - cloudSidecarJson.parseElementOrNull(richTextFile.readText())?.let { element -> - put("text", SharedPdfRichTextSerializer.encodeElement(SharedPdfRichTextSerializer.decodeElement(element))) + desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations -> + put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) } } + if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) { + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText()) + put( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, + SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions) + ) + } + if (richTextFile.isFile) { + desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) } + } + } + if (data.isEmpty()) { + logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" } + return null } - if (data.isEmpty()) return null val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data) val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson( cloudSidecarJson.encodeToString(JsonElement.serializer(), payload) @@ -69,48 +133,129 @@ internal object DesktopCloudSidecarSync { ) tempFile.parentFile?.mkdirs() tempFile.writeText(canonical) + logDesktopCloudAnnotations { + "desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " + + "canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}" + } return tempFile } fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean { - val path = book.path?.takeIf { it.isNotBlank() } ?: return false - if (book.type != FileType.PDF) return false - val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: return false + val path = book.path?.takeIf { it.isNotBlank() } ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" } + return false + } + if (book.type != FileType.PDF) { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" } + return false + } + val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" } + return false + } val data = root["data"]?.jsonObjectOrNull() ?: root val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data) val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) val bookmarkFile = desktopPdfBookmarkFile(path) val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " + + "rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " + + localAnnotationDebugSummary(book) + } if (canonicalData.hasPdfAnnotationPayload()) { val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData) - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - annotationFile.setLastModified(timestamp) + if (annotations.isEmpty()) { + if (annotationFile.isFile) { + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" } + } + } else { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + annotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotations book=${book.id} count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}" + } + } } else if (annotationFile.isFile) { - annotationFile.delete() + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" } + } + + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData) + if (deletions.isEmpty()) { + if (deletedAnnotationFile.isFile) { + val deleted = deletedAnnotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" } + } + } else { + deletedAnnotationFile.parentFile?.mkdirs() + deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)) + deletedAnnotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " + + "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}" + } } canonicalData["bookmarks"]?.let { bookmarks -> bookmarkFile.parentFile?.mkdirs() bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks)) bookmarkFile.setLastModified(timestamp) - } ?: run { - if (bookmarkFile.isFile) bookmarkFile.delete() + logDesktopCloudAnnotations { + "desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}" + } } canonicalData["text"]?.let { richText -> val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) - richTextFile.parentFile?.mkdirs() - richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) - richTextFile.setLastModified(timestamp) + if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" } + } + } else { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + richTextFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " + + "spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}" + } + } } ?: run { - if (richTextFile.isFile) richTextFile.delete() + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" } + } + } + logDesktopCloudAnnotations { + "desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}" } return true } } +private fun localAnnotationPayloadTimestamp(path: String): Long { + return maxOf( + desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(), + desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(), + desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText() + ) +} + private val cloudSidecarJson = Json { ignoreUnknownKeys = true prettyPrint = true @@ -136,3 +281,31 @@ private fun JsonObject.hasPdfAnnotationPayload(): Boolean { private fun File.lastModifiedIfFile(): Long { return if (isFile) lastModified() else 0L } + +private fun File.hasSyncablePdfAnnotations(): Boolean { + return isFile && desktopPdfAnnotationElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableAnnotations(): Long { + return if (hasSyncablePdfAnnotations()) lastModified() else 0L +} + +private fun File.annotationDeletionCount(): Int { + return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0 +} + +private fun File.hasSyncablePdfAnnotationDeletions(): Boolean { + return annotationDeletionCount() > 0 +} + +private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long { + return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L +} + +private fun File.hasSyncablePdfRichText(): Boolean { + return isFile && desktopPdfRichTextElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableRichText(): Long { + return if (hasSyncablePdfRichText()) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt index 9b0303d..846f6bf 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSync.kt @@ -7,9 +7,17 @@ import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.EpubBookmark import com.aryan.reader.shared.FileType import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.SharedCloudBookMetadataWinner import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.ShelfRecord +import com.aryan.reader.shared.sharedCloudBookMetadataWinner +import com.aryan.reader.shared.shouldDownloadRemoteCloudBookContent +import com.aryan.reader.shared.shouldUploadLocalCloudBookContent +import com.aryan.reader.shared.sharedCloudBookContentFileName +import com.aryan.reader.shared.toStablePositionCfi +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport import com.aryan.reader.shared.reader.ReaderBookmark import java.io.File @@ -31,7 +39,8 @@ internal data class DesktopCloudSyncResult( val shelfRefs: List, val customFonts: List, val uploadedBooks: Int = 0, - val downloadedBooks: Int = 0 + val downloadedBooks: Int = 0, + val pendingContentDownloads: Int = 0 ) internal class DesktopCloudSync( @@ -47,13 +56,22 @@ internal class DesktopCloudSync( var customFonts = input.customFonts var uploadedBooks = 0 var downloadedBooks = 0 + var pendingContentDownloads = 0 + logDesktopCloudSync { + "desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " + + "localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}" + } val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken) .filterNot { isDesktopPdfReflowBookId(it.bookId) } .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken) val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken) var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } + logDesktopCloudSync { + "desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " + + "remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}" + } val localBooks = state.rawLibraryBooks .filterNot { isDesktopPdfReflowBookId(it.id) } @@ -71,6 +89,7 @@ internal class DesktopCloudSync( when { local != null && remote == null -> { + logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" } uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced -> state = state.upsertCloudBook(synced) uploadedBooks += 1 @@ -78,22 +97,33 @@ internal class DesktopCloudSync( } local == null && remote != null -> { - if (remote.isDeleted) return@forEach + if (remote.isDeleted) { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" } + return@forEach + } + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" } val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles) - val remoteBook = downloaded ?: remote.toDesktopBookItem() + if (downloaded == null) { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_remote_new_pending_content " + + remote.desktopCloudSyncSummary() + } + return@forEach + } + val remoteBook = downloaded state = state.upsertCloudBook(remoteBook) - if (downloaded != null) downloadedBooks += 1 + downloadedBooks += 1 + importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp) if (remote.hasAnnotations) { - downloadAnnotations(input.driveAccessToken, remoteBook, remote.lastModifiedTimestamp) + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles) + ) + downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp) } } local != null && remote != null -> { - if (remote.isDeleted) { - state = state.removeCloudBook(bookId) - return@forEach - } - val remoteBook = remote.toDesktopBookItem(existing = local) val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote) val downloaded = if (shouldDownloadContent) { @@ -101,24 +131,165 @@ internal class DesktopCloudSync( } else { null } - val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) - val localMetadataTimestamp = maxOf(local.timestamp, localSidecarTimestampBeforeMerge) - - if (localMetadataTimestamp > remote.lastModifiedTimestamp) { - uploadBookAndMetadata(input, local, uploadContent = shouldUploadLocalBookContent(local, remote))?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 + if (shouldDownloadContent && downloaded == null) { + pendingContentDownloads += 1 + } + val localContentAvailable = local.path?.let(::File)?.isFile == true + if (shouldDownloadContent && downloaded == null && !localContentAvailable) { + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() } - } else if (remote.lastModifiedTimestamp > local.timestamp || downloaded != null) { - state = state.upsertCloudBook(downloaded ?: remoteBook) + state = state.removeCloudBook(bookId) + return@forEach + } + val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) + val metadataWinner = sharedCloudBookMetadataWinner( + localModifiedTimestamp = local.timestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL + val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles) + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) + val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp + val localAnnotationsShouldUpload = shouldUploadLocalAnnotations( + local = local, + remote = remote, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + localSidecarTimestamp = localSidecarTimestampBeforeMerge + ) + logDesktopCloudAnnotations { + "desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localShouldUpload=$localAnnotationsShouldUpload " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(local) + } + logDesktopCloudSync { + "desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " + + "downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() } - val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationTimestamp(downloaded ?: local) - val needsAnnotationDownload = remote.hasAnnotations && - (remote.lastModifiedTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) + if (remote.isDeleted) { + if (localMetadataWins) { + logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" } + uploadBookAndMetadata( + input = input, + book = local, + uploadContent = shouldUploadLocalBookContent(local, null), + uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local) + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) { + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" } + state = state.removeCloudBook(bookId) + } else { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" } + } + return@forEach + } + + if (localMetadataWins) { + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local book=$bookId " + + "uploadContent=${shouldUploadLocalBookContent(local, remote)} " + + "uploadAnnotations=$localAnnotationsShouldUpload " + + "preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}" + } + val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) { + local.withCloudReadingPosition(remote) + } else { + local + } + val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false) + uploadBookAndMetadata( + input = input, + book = bookForMetadata, + uploadContent = shouldUploadLocalBookContent(local, remote), + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) { + logDesktopCloudSync { + "desktop.engine.book_decision action=apply_remote book=$bookId " + + "metadataWinner=$metadataWinner downloadedContent=${downloaded != null}" + } + val mergedBook = downloaded ?: remoteBook + state = state.upsertCloudBook(mergedBook) + importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp) + } + + if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) { + val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?: remoteBook + logDesktopCloudAnnotations { + "desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " + + "remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp" + } + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local_supplement book=$bookId " + + "metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + metadataBook.desktopCloudSyncSummary() + } + uploadBookAndMetadata( + input = input, + book = metadataBook, + uploadContent = false, + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } + + val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local) + val needsAnnotationDownload = !localMetadataWins && + !localAnnotationsShouldUpload && + remote.hasAnnotations && + (remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) if (needsAnnotationDownload) { + logDesktopCloudAnnotations { + "desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local - downloadAnnotations(input.driveAccessToken, targetBook, remote.lastModifiedTimestamp) + downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp) + } else { + logDesktopCloudAnnotations { + "desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } } } } @@ -135,18 +306,49 @@ internal class DesktopCloudSync( val localFile = book.path?.let(::File) when { localFile?.isFile == true && driveFiles[driveName] == null -> { - if (driveRepository.uploadFile(input.driveAccessToken, book.id, localFile, book.type) != null) { - uploadedBooks += 1 + val remote = remoteBooksMap[book.id] + if (remote == null || shouldUploadLocalBookContent(book, remote)) { + logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" } + uploadBookAndMetadata( + input = input, + book = book, + uploadContent = true, + uploadAnnotations = false, + remoteHasAnnotations = remote?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(book.id, driveFiles) + ) ?: 0L, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " + + "localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}" + } } } (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> { val remote = remoteBooksMap[book.id] ?: return@forEach - downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)?.let { downloaded -> + logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" } + val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles) + if (downloaded != null) { state = state.upsertCloudBook(downloaded) downloadedBooks += 1 + } else { + pendingContentDownloads += 1 } } + + (localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> { + pendingContentDownloads += 1 + logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" } + state = state.removeCloudBook(book.id) + } } } @@ -172,55 +374,205 @@ internal class DesktopCloudSync( remoteFonts = remoteFonts ) + logDesktopCloudSync { + "desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " + + "pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}" + } return DesktopCloudSyncResult( state = state, shelfRecords = shelfRecords, shelfRefs = shelfRefs, customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }, uploadedBooks = uploadedBooks, - downloadedBooks = downloadedBooks + downloadedBooks = downloadedBooks, + pendingContentDownloads = pendingContentDownloads ) } suspend fun uploadBookAndMetadata( input: DesktopCloudSyncInput, book: BookItem, - uploadContent: Boolean + uploadContent: Boolean, + uploadAnnotations: Boolean = true, + remoteHasAnnotations: Boolean = false, + remoteAnnotationModifiedTimestamp: Long = 0L, + remoteContentModifiedTimestamp: Long? = null ): BookItem? { - if (isDesktopPdfReflowBookId(book.id)) return null - if (book.sourceFolder != null) return null - if (book.path?.startsWith("opds-pse") == true) return null - if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return null + if (isDesktopPdfReflowBookId(book.id)) { + logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.sourceFolder != null) { + logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.path?.startsWith("opds-pse") == true) { + logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" } + return null + } + if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) { + logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" } + return null + } + logDesktopCloudSync { + "desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " + + "remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}" + } if (uploadContent) { val source = book.path?.let(::File)?.takeIf { it.isFile } if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) { + logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" } return null } + logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" } } - val bundle = DesktopCloudSidecarSync.exportAnnotationBundle(book) + val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book) + val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations) + val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null + var uploadedAnnotationTimestamp = 0L + logDesktopCloudAnnotations { + "desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " + + "remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " + + "bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } try { - if (bundle != null && driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) == null) { - return null + if (bundle != null) { + val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle( + accessToken = input.driveAccessToken, + book = book, + bundle = bundle, + remoteHasAnnotations = remoteHasAnnotations + ) + val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) + if (uploadedAnnotationFile == null) { + logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + return null + } + uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis + if (mergedRemoteIntoUpload) { + val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle( + book = book, + rawJson = bundle.readText(), + timestamp = uploadedAnnotationTimestamp + ) + logDesktopCloudAnnotations { + "desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " + + "driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}" + } + } + DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp) + } + if (bundle != null) { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp" + } + } else { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations" + } + } + logDesktopCloudSync { + "desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " + + "shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}" } } finally { bundle?.delete() } val now = System.currentTimeMillis() - val syncedBook = book.copy(timestamp = now) + val syncedBook = book.copy( + timestamp = now, + readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp() + ) + val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book) + val syncedAnnotationTimestamp = if (bundle != null) { + uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now) + } else if (remoteHasAnnotations) { + remoteAnnotationModifiedTimestamp + } else { + 0L + } + val syncedHasAnnotations = if (uploadAnnotations) { + syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations) + } else { + remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations + } firestoreRepository.syncBookMetadata( userId = input.userId, book = syncedBook.toDesktopCloudBookMetadata( - hasAnnotations = bundle != null, - timestamp = now + hasAnnotations = syncedHasAnnotations, + timestamp = now, + annotationModifiedTimestamp = syncedAnnotationTimestamp, + contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp ), originDeviceId = input.deviceId, idToken = input.idToken ) + logDesktopCloudSync { + "desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " + + "oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " + + syncedBook.desktopCloudSyncSummary("synced") + } + logDesktopCloudAnnotations { + "desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " + + "readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations" + } return syncedBook } + private suspend fun mergeRemoteAnnotationsIntoUploadBundle( + accessToken: String, + book: BookItem, + bundle: File, + remoteHasAnnotations: Boolean + ): Boolean { + if (!remoteHasAnnotations || !bundle.isFile) return false + val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") + try { + val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp) + if (!didDownload || !remoteTemp.isFile) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " + + "tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}" + } + return false + } + val localRaw = bundle.readText() + val remoteRaw = remoteTemp.readText() + val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson( + localDataJson = localRaw, + remoteDataJson = remoteRaw, + preferRemoteOnConflict = false + ) + val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw) + val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw) + val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw) + if (mergedRaw != localRaw) { + bundle.writeText(mergedRaw) + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}" + } + return true + } else { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount" + } + } + } catch (error: Exception) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}" + } + } finally { + remoteTemp.delete() + } + return false + } + suspend fun deleteBooksFromCloud( userId: String, idToken: String, @@ -247,7 +599,7 @@ internal class DesktopCloudSync( desktopCloudBookDriveFileName(book.id, book.type) ?.let { driveFiles[it]?.id } ?.let { driveRepository.deleteDriveFile(accessToken, it) } - driveFiles["annotation_${book.id}.json"]?.id + driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id ?.let { driveRepository.deleteDriveFile(accessToken, it) } } } @@ -293,8 +645,32 @@ internal class DesktopCloudSync( private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean { val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") return try { - if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) return false - DesktopCloudSidecarSync.importAnnotationBundle(book, temp.readText(), timestamp) + logDesktopCloudAnnotations { + "desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" } + if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) { + logDesktopCloudAnnotations { + "desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}" + } + logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" } + return false + } + val raw = temp.readText() + logDesktopCloudAnnotations { + "desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}" + } + val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L + val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp) + logDesktopCloudAnnotations { + "desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { + "desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied" + } + applied } finally { temp.delete() } @@ -311,16 +687,23 @@ internal class DesktopCloudSync( val driveFile = driveFiles[driveName] ?: return null val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension") + logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" } if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) { destination.delete() + logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" } return null } val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified() if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp) - return remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( + val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( fileSize = destination.length(), fileContentModifiedTimestamp = contentTimestamp ) + logDesktopCloudSync { + "desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " + + downloaded.desktopCloudSyncSummary("downloaded") + } + return downloaded } private suspend fun syncFonts( @@ -447,18 +830,28 @@ internal class DesktopCloudSync( internal fun BookItem.toDesktopCloudBookMetadata( hasAnnotations: Boolean, - timestamp: Long = this.timestamp + timestamp: Long = this.timestamp, + annotationModifiedTimestamp: Long = 0L, + contentTimestampOverride: Long? = null ): DesktopCloudBookMetadata { - val position = readerPosition - val bookmarksJson = readerBookmarks - .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } - .takeIf { it.isNotEmpty() } - ?.let(EpubAnnotationSerializer::bookmarksToJson) - val highlightsJson = readerHighlights - .takeIf { it.isNotEmpty() } - ?.let(EpubAnnotationSerializer::highlightsToJson) + val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() } + val supportsReaderAnnotations = type.usesCloudLocatorMetadata() + val bookmarksJson = desktopPdfBookmarksMetadataJson(this) + ?: if (supportsReaderAnnotations) { + readerBookmarks + .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } + .let(EpubAnnotationSerializer::bookmarksToJson) + } else { + null + } + val highlightsJson = if (supportsReaderAnnotations) { + EpubAnnotationSerializer.highlightsToJson(readerHighlights) + } else { + null + } val localFile = path?.let(::File) - val contentTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } + val contentTimestamp = contentTimestampOverride + ?: fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile?.takeIf { it.isFile }?.lastModified() ?: 0L return DesktopCloudBookMetadata( @@ -469,13 +862,15 @@ internal fun BookItem.toDesktopCloudBookMetadata( type = type.name, lastPositionCfi = position?.cloudPositionCfi(), lastChapterIndex = position?.chapterIndex, - locatorBlockIndex = null, - locatorCharOffset = null, - lastPage = position?.pageIndex ?: lastPageIndex, + locatorBlockIndex = position?.blockIndex, + locatorCharOffset = position?.charOffset, + lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex, progressPercentage = progressPercentage, isRecent = isRecent, isDeleted = false, lastModifiedTimestamp = timestamp, + readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(), + annotationModifiedTimestamp = annotationModifiedTimestamp, bookmarksJson = bookmarksJson, hasAnnotations = hasAnnotations, fileContentModifiedTimestamp = contentTimestamp, @@ -498,11 +893,39 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem( ): BookItem { val type = fileType() val pageIndex = lastPage - val locator = ReaderLocator.fromLegacy( - chapterIndex = lastChapterIndex, - cfi = lastPositionCfi, - pageIndex = pageIndex - ) + val locator = if (type.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset + ) + } else { + null + } + val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp() + val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val useRemoteReadingPosition = existing == null || + remoteReadingTimestamp > localReadingTimestamp || + (localReadingTimestamp == 0L && hasCloudReadingPosition()) + val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex + val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) { + if (useRemoteReadingPosition) { + locator?.takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } ?: existing?.readerPosition + } else { + existing?.readerPosition + } + } else { + null + } return BookItem( id = bookId, path = downloadedPath ?: existing?.path, @@ -518,7 +941,7 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem( originalSeriesName = originalSeriesName ?: existing?.originalSeriesName, originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex, originalDescription = originalDescription ?: existing?.originalDescription, - progressPercentage = progressPercentage ?: existing?.progressPercentage, + progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage, isRecent = isRecent, fileSize = existing?.fileSize ?: 0L, fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } @@ -529,12 +952,10 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem( seriesName = seriesName ?: existing?.seriesName, seriesIndex = seriesIndex ?: existing?.seriesIndex, tags = existing?.tags.orEmpty(), - lastPageIndex = pageIndex ?: existing?.lastPageIndex, - readerPosition = locator.takeIf { - it.chapterIndex != null || it.pageIndex != null || it.cfi != null || it.startOffset != null - } ?: existing?.readerPosition, + lastPageIndex = restoredPageIndex, + readerPosition = restoredReaderPosition, readerSettings = existing?.readerSettings, - readerBookmarks = if (bookmarksJson.isNullOrBlank()) { + readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) { existing?.readerBookmarks.orEmpty() } else { EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark -> @@ -552,10 +973,101 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem( } else { EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) }, - pdfReaderViewport = existing?.pdfReaderViewport + pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport, + readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp ) } +internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem { + val remoteType = remote.fileType() + val pageIndex = remote.lastPage + val locator = if (remoteType.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = remote.lastChapterIndex, + cfi = remote.lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = remote.locatorBlockIndex, + charOffset = remote.locatorCharOffset + ).takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } + } else { + null + } + return copy( + lastPageIndex = pageIndex ?: lastPageIndex, + readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null, + progressPercentage = remote.progressPercentage ?: progressPercentage, + pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) { + pdfReaderViewport + } else { + remote.remotePdfViewport(this, pageIndex) + }, + readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + ) +} + +private fun DesktopCloudBookMetadata.remotePdfViewport( + existing: BookItem?, + pageIndex: Int? +): SharedPdfReaderViewport? { + if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport + val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport() + return base.copy( + pageIndex = pageIndex, + horizontalScrollOffset = 0, + paginatedVerticalScrollOffset = 0, + verticalFirstPageIndex = pageIndex, + verticalFirstPageScrollOffset = 0 + ) +} + +private fun FileType.usesCloudLocatorMetadata(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +internal fun BookItem.hasCloudReadingPosition(): Boolean { + return lastPageIndex != null || + readerPosition != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: timestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean { + return lastChapterIndex != null || + lastPage != null || + !lastPositionCfi.isNullOrBlank() || + locatorBlockIndex != null || + locatorCharOffset != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long { + return annotationModifiedTimestamp.takeIf { it > 0L } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long { + return sidecarModifiedTimestamp.takeIf { it > 0L } + ?: effectiveCloudAnnotationModifiedTimestamp() +} + internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata { return DesktopCloudFontMetadata( id = id, @@ -568,8 +1080,7 @@ internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetada } internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? { - val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null - return "$bookId.$extension" + return sharedCloudBookContentFileName(bookId, type) } private data class DesktopCloudShelfRecord( @@ -613,18 +1124,53 @@ private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopClou ?: localFile?.takeIf { it.isFile }?.lastModified() ?: 0L return local.sourceFolder == null && - !remote.isDeleted && remote.fileType() == local.type && - remote.fileContentModifiedTimestamp > 0L && - (localFile == null || !localFile.isFile || remote.fileContentModifiedTimestamp > localTimestamp) + shouldDownloadRemoteCloudBookContent( + localFileAvailable = localFile?.isFile == true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp, + remoteDeleted = remote.isDeleted + ) } private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean { val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified() return local.sourceFolder == null && - localTimestamp > 0L && - localTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L) + shouldUploadLocalCloudBookContent( + localFileAvailable = true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + ) +} + +private fun shouldUploadLocalAnnotations( + local: BookItem, + remote: DesktopCloudBookMetadata?, + remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L, + localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local) +): Boolean { + return DesktopCloudSidecarSync.hasLocalAnnotationData(local) && + (remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp) +} + +private fun remoteAnnotationDriveFileTimestamp( + bookId: String, + driveFiles: Map +): Long { + return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L +} + +internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json" + +private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem { + if (downloaded == null) return this + return copy( + path = if (replacePath) downloaded.path ?: path else path, + fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: fileContentModifiedTimestamp + ) } private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long { @@ -634,17 +1180,7 @@ private fun desktopShelfTimestamp(record: ShelfRecord, refs: List) } private fun ReaderLocator.cloudPositionCfi(): String? { - cfi?.let { return it } - val chapter = chapterIndex - val start = startOffset - val end = endOffset ?: start - return if (chapter != null && start != null && end != null) { - "desktop:$chapter:$start:$end" - } else if (chapter != null && pageIndex != null) { - "desktop:$chapter:$pageIndex" - } else { - null - } + return toStablePositionCfi() } private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncDiagnostics.kt new file mode 100644 index 0000000..37c5517 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCloudSyncDiagnostics.kt @@ -0,0 +1,82 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.SharedFileCapabilities + +internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync" +internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations" + +internal fun logDesktopCloudSync(message: () -> String) { + logDesktopDiagnostic(DesktopCloudSyncLogTag, message) +} + +internal fun logDesktopCloudAnnotations(message: () -> String) { + logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message) +} + +internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String { + val position = readerPosition + val page = if (type.usesCloudLocatorForDiagnostics()) { + position?.pageIndex ?: lastPageIndex + } else { + lastPageIndex + } + return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "contentTs=$fileContentModifiedTimestamp " + + "page=$page chapter=${position?.chapterIndex} " + + "block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " + + "cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " + + "bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}" +} + +internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " + + "recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " + + "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean { + val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex + val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) { + other.readerPosition?.pageIndex ?: other.lastPageIndex + } else { + other.lastPageIndex + } + val thisProgress = progressPercentage + val otherProgress = other.progressPercentage + val progressMatches = when { + thisProgress == null && otherProgress == null -> true + thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f + else -> false + } + val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) { + readerPosition == other.readerPosition + } else { + true + } + return thisPage == otherPage && + locatorMatches && + progressMatches +} + +private fun com.aryan.reader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +private fun String?.cloudSyncPreview(maxLength: Int = 80): String { + val value = this ?: return "null" + return if (value.length <= maxLength) value else value.take(maxLength) + "..." +} + +private fun String?.cloudSyncAnnotationSummary(): String { + val value = this?.trim() ?: return "null" + return when { + value.isEmpty() -> "blank" + value == "[]" -> "empty" + else -> "present(${value.length})" + } +} 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 e5ef065..c411916 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt @@ -1,6 +1,7 @@ package com.aryan.reader.desktop import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.opds.OpdsCatalog import com.aryan.reader.shared.opds.OpdsStreamReference import com.sun.jna.Library @@ -8,8 +9,9 @@ import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.ptr.PointerByReference import org.apache.commons.compress.archivers.sevenz.SevenZFile -import java.awt.Font +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import java.awt.Color +import java.awt.Font import java.awt.RenderingHints import java.awt.image.BufferedImage import java.io.ByteArrayInputStream @@ -24,7 +26,7 @@ import javax.imageio.ImageIO import kotlin.math.roundToInt internal object DesktopComicArchive { - private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7) + private val comicTypes = SharedFileCapabilities.comicArchiveTypes private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") fun canLoad(type: FileType): Boolean = type in comicTypes @@ -36,6 +38,7 @@ internal object DesktopComicArchive { FileType.CBZ -> loadZip(file) FileType.CBR -> loadRar(file) FileType.CB7 -> loadSevenZ(file) + FileType.CBT -> loadTar(file) else -> error("${type.name} is not a comic archive type.") } } @@ -162,6 +165,31 @@ internal object DesktopComicArchive { } } + private fun loadTar(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + return try { + val extracted = mutableListOf() + TarArchiveInputStream(file.inputStream().buffered()).use { archive -> + var entry = archive.nextEntry + while (entry != null) { + val name = entry.name.orEmpty() + if (!entry.isDirectory && name.isComicImageName()) { + val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") + target.outputStream().use { output -> + archive.copyTo(output) + } + extracted += ExtractedComicPage(name = name, file = target) + } + entry = archive.nextEntry + } + } + documentFromExtracted(file, extracted, tempDir) + } catch (throwable: Throwable) { + runCatching { tempDir.deleteRecursively() } + throw throwable + } + } + private fun loadWithArchiveCommand(file: File): DesktopComicDocument { val tempDir = Files.createTempDirectory("reader-comic-").toFile() return try { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt index 35a2855..6c2ee5f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopDiagnostics.kt @@ -1,16 +1,40 @@ package com.aryan.reader.desktop internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" +private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" +private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" +private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" + +private val DesktopDiagnosticTags: Set = + listOfNotNull( + System.getProperty(DesktopDiagnosticsTagsProperty), + System.getenv(DesktopDiagnosticsTagsEnv) + ) + .joinToString(" ") + .split(',', ';', ' ', '\t', '\n') + .mapNotNull { rawTag -> + rawTag.trim() + .takeIf { it.isNotBlank() } + ?.lowercase() + } + .toSet() internal val DesktopDiagnosticsEnabled: Boolean = - desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) + desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) || + desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) || + DesktopDiagnosticTags.isNotEmpty() internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { return rawValue?.trim()?.equals("true", ignoreCase = true) == true } -internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) { - if (DesktopDiagnosticsEnabled) { +private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean { + if (DesktopDiagnosticTags.isEmpty()) return true + return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags +} + +internal fun logDesktopDiagnostic(tag: String, message: () -> String) { + if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) { println("$tag ${message()}") } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt index cff8f9f..1135c79 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt @@ -1,6 +1,7 @@ package com.aryan.reader.desktop import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.toStableReaderPositionCfi import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull @@ -46,7 +47,8 @@ internal data class DesktopEpubHandledLink( internal enum class DesktopReaderSelectionAction { DEFINE, SPEAK, - SEARCH + SEARCH, + PALETTE } internal enum class DesktopReaderKeyNavigation { @@ -59,7 +61,10 @@ internal enum class DesktopReaderKeyNavigation { EXIT_FULLSCREEN } -internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? { +internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull( + fullscreen: Boolean, + rightToLeftPagination: Boolean = false +): DesktopReaderKeyNavigation? { if (id != AwtKeyEvent.KEY_PRESSED) return null if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { return DesktopReaderKeyNavigation.EXIT_FULLSCREEN @@ -71,9 +76,17 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): return DesktopReaderKeyNavigation.NEXT_SEARCH } return when (keyCode) { - AwtKeyEvent.VK_RIGHT, + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.PREVIOUS + } else { + DesktopReaderKeyNavigation.NEXT + } + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.NEXT + } else { + DesktopReaderKeyNavigation.PREVIOUS + } 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 @@ -83,7 +96,8 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): internal data class DesktopReaderSelectionActionPayload( val action: DesktopReaderSelectionAction, - val text: String + val text: String, + val locator: ReaderLocator? = null ) internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { @@ -128,9 +142,27 @@ internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionP "define" -> DesktopReaderSelectionAction.DEFINE "speak" -> DesktopReaderSelectionAction.SPEAK "web-search", "search" -> DesktopReaderSelectionAction.SEARCH + "palette" -> DesktopReaderSelectionAction.PALETTE else -> return@runCatching null } - DesktopReaderSelectionActionPayload(action, text) + val locator = obj["locator"] + ?.takeUnless { it is JsonNull } + ?.jsonObject + ?.let { locatorObj -> + ReaderLocator( + chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() + ) + } + DesktopReaderSelectionActionPayload(action, text, locator) }.getOrNull() parse(this)?.let { return it } @@ -181,11 +213,15 @@ internal fun String.readerPositionOrNull(): DesktopReaderPosition? { ?: return@runCatching null val locator = ReaderLocator( chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, pageIndex = pageIndex, startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() ) DesktopReaderPosition(pageIndex, locator) }.getOrNull() @@ -284,9 +320,7 @@ private fun String.readerHrefFromIntercept(): String? { val trimmed = trim() if (trimmed.isBlank()) return null if (trimmed.equals("about:blank", ignoreCase = true)) return null - if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file://", ignoreCase = true)) return null + if (trimmed.startsWith("file:/", ignoreCase = true)) return null if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" if (trimmed.startsWith("data:", ignoreCase = true)) return null if (trimmed.startsWith("blob:", ignoreCase = true)) return null @@ -298,9 +332,13 @@ internal fun ReaderLocator.toReaderLocatorJson(): String { append("{") val values = buildList { chapterIndex?.let { add("\"chapterIndex\":$it") } + chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") } + href?.let { add("\"href\":${it.toJsonStringLiteral()}") } pageIndex?.let { add("\"pageIndex\":$it") } startOffset?.let { add("\"startOffset\":$it") } endOffset?.let { add("\"endOffset\":$it") } + blockIndex?.let { add("\"blockIndex\":$it") } + charOffset?.let { add("\"charOffset\":$it") } cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt index dc0be71..0084d44 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.aryan.reader.shared.reader.ReaderLayoutSignature +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderViewportSpec import com.aryan.reader.shared.reader.SharedEpubBook @@ -28,6 +30,44 @@ internal data class DesktopEpubPaginationDensity( val fontScale: Float ) +internal fun desktopMeasuredPaginationReady( + request: DesktopEpubPaginationRequest?, + completedRequest: DesktopEpubPaginationRequest?, + currentPages: List, + measuredPages: List +): Boolean { + return request != null && + completedRequest == request && + measuredPages.isNotEmpty() && + currentPages.samePageLayoutAs(measuredPages) +} + +internal fun desktopPaginatedLayoutReadyForDisplay( + readingMode: ReaderReadingMode, + measuredPagesApplied: Boolean +): Boolean { + return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied +} + +internal fun desktopPagesWithMeasuredChapter( + currentPages: List, + chapterIndex: Int, + measuredChapterPages: List +): List { + if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages + val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex } + if (firstChapterPage < 0) return currentPages + val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex } + val combined = currentPages.take(firstChapterPage) + + measuredChapterPages + + currentPages.drop(lastChapterPage + 1) + return combined.mapIndexed { index, page -> page.copy(pageIndex = index) } +} + +internal fun List.firstPageIndexForChapter(chapterIndex: Int): Int? { + return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } +} + internal fun SharedEpubBook.desktopPaginationContentSignature(): Int { return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> 31 * acc + diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt index 1d351a3..972e324 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubWebView.kt @@ -1,57 +1,85 @@ package com.aryan.reader.desktop -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.UserHighlight -import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.ui.ReaderContentNavigationTarget -import com.multiplatform.webview.jsbridge.IJsMessageHandler -import com.multiplatform.webview.jsbridge.JsMessage -import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge -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 kotlinx.coroutines.launch -import java.awt.AWTEvent -import java.awt.Toolkit -import java.awt.event.AWTEventListener -import java.awt.event.MouseEvent @Composable internal fun DesktopEpubWebView( html: String, appearanceScript: String, + highlightPaletteScript: String, navigationTarget: ReaderContentNavigationTarget, highlights: List, onHighlightCreated: (UserHighlight) -> Unit, onHighlightSelected: (String) -> Unit, isFullscreen: Boolean, onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, - onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, onLinkClicked: (DesktopEpubLinkClick) -> Unit, onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, onPointerActivity: () -> Unit = {}, networkAccessEnabled: Boolean, + backgroundColor: Color, modifier: Modifier = Modifier ) { + val backend = desktopEpubWebViewBackend() + LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) { + logDesktopWebView2( + "backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " + + "navMode=${navigationTarget.readingMode}" + } + } + DesktopNativeSwtEpubWebView( + html = html, + appearanceScript = appearanceScript, + highlightPaletteScript = highlightPaletteScript, + navigationTarget = navigationTarget, + highlights = highlights, + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity, + networkAccessEnabled = networkAccessEnabled, + backgroundColor = backgroundColor, + modifier = modifier + ) +} + +internal data class DesktopEpubBridgeHandler( + val methodName: String, + val onMessage: (String) -> Unit +) + +@Composable +internal fun rememberDesktopEpubBridgeHandlers( + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit +): List { val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected) val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation) @@ -60,81 +88,98 @@ internal fun DesktopEpubWebView( val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) val latestOnPointerActivity by rememberUpdatedState(onPointerActivity) val scope = rememberCoroutineScope() - 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( - "request_intercept method=${request.method} redirect=${request.isRedirect} " + - "url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\"" - ) - scope.launch { - latestOnLinkClicked(link.copy(source = "request")) - } - return WebRequestInterceptResult.Reject - } - } - } - val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor) - val bridge = rememberWebViewJsBridge() - - DisposableEffect(bridge) { - val handlers = listOf( - desktopEpubBridgeHandler("readerHighlightCreated") { message -> - val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params) + return remember(scope) { + listOf( + DesktopEpubBridgeHandler("readerHighlightCreated") { params -> + logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"") + val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params) if (highlight == null) { - logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}") + logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated") + logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}") } else { + logEpubHighlightFlow( + "bridge_parse_success id=${highlight.id} color=${highlight.color.id} " + + "chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" + ) + logDesktopHighlightMap( + "bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " + + "chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" + ) scope.launch { latestOnHighlightCreated(highlight) } } }, - desktopEpubBridgeHandler("readerHighlightClicked") { message -> - message.params.readerHighlightClickOrNull()?.let { highlightClick -> + DesktopEpubBridgeHandler("readerHighlightClicked") { params -> + params.readerHighlightClickOrNull()?.let { highlightClick -> scope.launch { latestOnHighlightSelected(highlightClick.highlightId) } } }, - desktopEpubBridgeHandler("readerPositionChanged") { message -> - message.params.readerPositionOrNull()?.let { position -> + DesktopEpubBridgeHandler("readerPositionChanged") { params -> + params.readerPositionOrNull()?.let { position -> + logDesktopPositionTrace( + "event=bridge_position_changed page=${position.pageIndex} " + + "locator=${position.locator.desktopPositionTraceSummary()}" + ) + logDesktopHighlightMap( + "bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " + + "offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " + + "block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " + + "chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " + + "text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " + + "cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\"" + ) + logDesktopTtsStartTrace { + "event=bridge_position_changed page=${position.pageIndex} " + + "locator=${position.locator.desktopPositionTraceSummary(160)}" + } scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) } } }, - desktopEpubBridgeHandler("readerSelectionAction") { message -> - val selectionAction = message.params.readerSelectionActionOrNull() + DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params -> + logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params -> + logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) } + }, + DesktopEpubBridgeHandler("readerSelectionAction") { params -> + val selectionAction = params.readerSelectionActionOrNull() if (selectionAction != null) { - scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) } + scope.launch { latestOnSelectionAction(selectionAction) } } }, - desktopEpubBridgeHandler("readerKeyNavigation") { message -> - message.params.readerKeyNavigationOrNull()?.let { action -> + DesktopEpubBridgeHandler("readerKeyNavigation") { params -> + params.readerKeyNavigationOrNull()?.let { action -> scope.launch { latestOnKeyboardNavigation(action) } } }, - desktopEpubBridgeHandler("readerPointerActivity") { _ -> + DesktopEpubBridgeHandler("readerPointerActivity") { scope.launch { latestOnPointerActivity() } }, - desktopEpubBridgeHandler("readerTtsHighlightLog") { message -> - logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}") + DesktopEpubBridgeHandler("readerTtsHighlightLog") { params -> + logDesktopTts("epub_highlight_js ${params.logPreview(500)}") }, - desktopEpubBridgeHandler("readerSelectionDebugLog") { message -> - logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900)) + DesktopEpubBridgeHandler("readerSelectionDebugLog") { params -> + logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) }, - desktopEpubBridgeHandler("readerPaginationLayoutLog") { message -> - logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) + DesktopEpubBridgeHandler("readerHighlightFlowLog") { params -> + logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) }, - desktopEpubBridgeHandler("readerGapLayoutLog") { message -> - logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900)) + DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params -> + logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) }, - desktopEpubBridgeHandler("readerLinkClicked") { message -> - logEpubLink("bridge_message params=\"${message.params.logPreview()}\"") - val link = message.params.readerLinkClickOrNull() + DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params -> + logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerGapLayoutLog") { params -> + logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) + }, + DesktopEpubBridgeHandler("readerLinkClicked") { params -> + logEpubLink("bridge_message params=\"${params.logPreview()}\"") + val link = params.readerLinkClickOrNull() if (link == null) { logEpubLink("bridge_message_ignored reason=parse_failed") } else { @@ -146,214 +191,70 @@ internal fun DesktopEpubWebView( } } ) - handlers.forEach { bridge.register(it) } - onDispose { - handlers.forEach { bridge.unregister(it) } - } - } - - 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, - mimeType = "text/html", - encoding = "utf-8", - historyUrl = null - ) - } - - DisposableEffect(Unit) { - var lastActivityAt = 0L - var lastMouseX: Int? = null - var lastMouseY: Int? = null - val listener = AWTEventListener { event -> - val mouseEvent = event as? MouseEvent ?: return@AWTEventListener - if ( - mouseEvent.id != MouseEvent.MOUSE_MOVED && - mouseEvent.id != MouseEvent.MOUSE_DRAGGED && - mouseEvent.id != MouseEvent.MOUSE_PRESSED && - mouseEvent.id != MouseEvent.MOUSE_WHEEL - ) { - return@AWTEventListener - } - if (mouseEvent.id == MouseEvent.MOUSE_MOVED || mouseEvent.id == MouseEvent.MOUSE_DRAGGED) { - val screenX = mouseEvent.xOnScreen - val screenY = mouseEvent.yOnScreen - if (lastMouseX == screenX && lastMouseY == screenY) return@AWTEventListener - lastMouseX = screenX - lastMouseY = screenY - } else { - lastMouseX = mouseEvent.xOnScreen - lastMouseY = mouseEvent.yOnScreen - } - val now = mouseEvent.`when`.takeIf { it > 0L } ?: System.currentTimeMillis() - if (now - lastActivityAt < 120L) return@AWTEventListener - lastActivityAt = now - scope.launch { latestOnPointerActivity() } - } - val eventMask = AWTEvent.MOUSE_MOTION_EVENT_MASK or - AWTEvent.MOUSE_EVENT_MASK or - AWTEvent.MOUSE_WHEEL_EVENT_MASK - Toolkit.getDefaultToolkit().addAWTEventListener(listener, eventMask) - onDispose { - Toolkit.getDefaultToolkit().removeAWTEventListener(listener) - } - } - - Box(modifier = modifier) { - WebView( - state = state, - modifier = Modifier.fillMaxSize(), - captureBackPresses = false, - navigator = navigator, - webViewJsBridge = bridge - ) - - LaunchedEffect(state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript(DesktopEpubKeyNavigationScript) - } - - LaunchedEffect(isFullscreen, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};") - } - - LaunchedEffect(html, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") - } - - LaunchedEffect(appearanceScript, state.loadingState) { - if (!state.loadingState.isFinished()) return@LaunchedEffect - navigator.evaluateJavaScript(appearanceScript) - } - - LaunchedEffect( - navigationTarget.autoScroll, - navigationTarget.readingMode, - state.loadingState - ) { - if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (!state.loadingState.isFinished()) 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 != ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (!state.loadingState.isFinished()) 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.isFinished()) 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.isFinished()) return@LaunchedEffect - val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) - navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") - } - - val loadingState = state.loadingState - if (loadingState is LoadingState.Loading) { - LinearProgressIndicator( - progress = { loadingState.progress }, - modifier = Modifier.fillMaxWidth() - ) - } } } -private fun desktopEpubBridgeHandler( - methodName: String, - onMessage: (JsMessage) -> Unit -): IJsMessageHandler { - return object : IJsMessageHandler { - override fun methodName(): String = methodName - - override fun handle( - message: JsMessage, - navigator: WebViewNavigator?, - callback: (String) -> Unit - ) { - onMessage(message) - } - } -} - -private fun LoadingState.isFinished(): Boolean = this is LoadingState.Finished - -private val DesktopEpubKeyNavigationScript = """ +internal val DesktopEpubKeyNavigationScript = """ (function () { - if (!window.readerDesktopPointerActivityInstalled) { - window.readerDesktopPointerActivityInstalled = true; - var lastPointerActivityAt = 0; - var lastPointerX = null; - var lastPointerY = null; - function notifyPointerActivity(event, requireMovement) { - if (requireMovement && event) { - var x = Math.round(event.screenX || event.clientX || 0); - var y = Math.round(event.screenY || event.clientY || 0); - if (lastPointerX === x && lastPointerY === y) return; - lastPointerX = x; - lastPointerY = y; - } - var now = Date.now(); - if (now - lastPointerActivityAt < 120) return; - lastPointerActivityAt = now; + if (!window.readerDesktopChromeTapInstalled) { + window.readerDesktopChromeTapInstalled = true; + var chromeTapStart = null; + var lastChromeTapNotifiedAt = 0; + function notifyChromeTap() { if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return; window.kmpJsBridge.callNative('readerPointerActivity', '{}'); + lastChromeTapNotifiedAt = Date.now(); } - document.addEventListener('mousemove', function (event) { notifyPointerActivity(event, true); }, true); - document.addEventListener('pointermove', function (event) { notifyPointerActivity(event, true); }, true); - document.addEventListener('pointerdown', function (event) { notifyPointerActivity(event, false); }, true); - document.addEventListener('wheel', function (event) { notifyPointerActivity(event, false); }, true); + function chromeTapIgnored(target) { + if (!target || !target.closest) return false; + return !!target.closest( + 'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle' + ); + } + function hasActiveReaderSelection() { + var selection = window.getSelection && window.getSelection(); + return !!selection && selection.toString().trim().length > 0; + } + function beginChromeTap(event) { + if (event.button !== undefined && event.button !== 0) return; + if (chromeTapIgnored(event.target)) { + chromeTapStart = null; + return; + } + chromeTapStart = { + pointerId: event.pointerId, + x: event.clientX || 0, + y: event.clientY || 0, + at: Date.now() + }; + } + function finishChromeTap(event) { + if (!chromeTapStart) return; + if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return; + var dx = (event.clientX || 0) - chromeTapStart.x; + var dy = (event.clientY || 0) - chromeTapStart.y; + var elapsed = Date.now() - chromeTapStart.at; + chromeTapStart = null; + if ((dx * dx + dy * dy) > 64 || elapsed > 650) return; + if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; + notifyChromeTap(); + } + function maybeNotifyChromeTapFromClick(event) { + if (Date.now() - lastChromeTapNotifiedAt < 250) return; + if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; + notifyChromeTap(); + } + document.addEventListener('pointerdown', beginChromeTap, true); + document.addEventListener('pointerup', finishChromeTap, true); + document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true); + document.addEventListener('click', function (event) { + if (window.PointerEvent) { + maybeNotifyChromeTapFromClick(event); + return; + } + beginChromeTap(event); + finishChromeTap(event); + }, true); } if (window.readerDesktopKeyNavigationInstalled) return; window.readerDesktopKeyNavigationInstalled = true; diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacement.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacement.kt new file mode 100644 index 0000000..17d198e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacement.kt @@ -0,0 +1,13 @@ +package com.aryan.reader.desktop + +internal data class DesktopFeatureNoticePlacement( + val readerWindowId: String? = null +) { + fun rendersInMainWindow(): Boolean = readerWindowId == null + + fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId +} + +internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement { + return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() }) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt index 0594804..21126ed 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFirebaseAuthRepository.kt @@ -68,7 +68,7 @@ internal class DesktopFirebaseAuthRepository( googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis ) session = nextSession - store.save(nextSession) + persistSession(nextSession) return nextSession } @@ -86,24 +86,30 @@ internal class DesktopFirebaseAuthRepository( val current = session ?: store.load()?.also { session = it } ?: return null if (current.isGoogleAccessTokenFresh) return current.googleAccessToken if (current.googleRefreshToken.isBlank()) return null - return runCatching { + val refreshed = runCatching { refreshGoogleAccessToken(current) - }.onSuccess { refreshed -> - session = refreshed - store.save(refreshed) - }.getOrNull()?.googleAccessToken + }.getOrNull() ?: return null + session = refreshed + persistSession(refreshed) + return refreshed.googleAccessToken } private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? { if (current.isFresh) return current - return runCatching { + val refreshed = runCatching { refreshFirebaseSession(current) - }.onSuccess { refreshed -> - session = refreshed - store.save(refreshed) }.onFailure { signOut() - }.getOrNull() + }.getOrNull() ?: return null + session = refreshed + persistSession(refreshed) + return refreshed + } + + private suspend fun persistSession(session: DesktopAuthSession) { + withContext(Dispatchers.IO) { + store.save(session) + } } private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) { @@ -364,24 +370,19 @@ internal class DesktopAuthStore( } fun save(session: DesktopAuthSession) { + val protectedRefreshToken = protectRequired(RefreshTokenKey, session.refreshToken) + val protectedGoogleRefreshToken = session.googleRefreshToken + .takeIf { it.isNotBlank() } + ?.let { protectRequired(GoogleRefreshTokenKey, it) } val properties = Properties().apply { setProperty("uid", session.user.uid) setProperty("displayName", session.user.displayName.orEmpty()) setProperty("photoUrl", session.user.photoUrl.orEmpty()) setProperty("email", session.user.email.orEmpty()) - runCatching { secretCodec.protect(RefreshTokenKey, session.refreshToken) } - .getOrNull() - ?.takeIf { it.isNotBlank() } - ?.let { setProperty(RefreshTokenKey, it) } - runCatching { secretCodec.protect(GoogleRefreshTokenKey, session.googleRefreshToken) } - .getOrNull() - ?.takeIf { it.isNotBlank() } - ?.let { setProperty(GoogleRefreshTokenKey, it) } - } - settingsFile.parentFile?.mkdirs() - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop account") + setProperty(RefreshTokenKey, protectedRefreshToken) + protectedGoogleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) } } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop account") } fun clear() { @@ -394,6 +395,17 @@ internal class DesktopAuthStore( const val RefreshTokenKey = "firebaseRefreshTokenProtected" const val GoogleRefreshTokenKey = "googleRefreshTokenProtected" } + + private fun protectRequired(keyName: String, value: String): String { + if (value.isBlank()) { + throw IllegalArgumentException("Cannot save a desktop account without a refresh token.") + } + val protectedValue = secretCodec.protect(keyName, value) + if (protectedValue.isBlank()) { + throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.") + } + return protectedValue + } } private val DesktopAuthJson = Json { ignoreUnknownKeys = true } 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 77e5f27..6d66c03 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt @@ -59,6 +59,16 @@ object DesktopFolderMetadataExtractor { return enrichBooks(books) { book -> book.sourceFolder == sourceFolder } } + fun enrichFolderBooks( + books: List, + sourceFolders: Set + ): DesktopFolderMetadataExtractionResult { + if (sourceFolders.isEmpty()) { + return DesktopFolderMetadataExtractionResult(books) + } + return enrichBooks(books) { book -> book.sourceFolder in sourceFolders } + } + fun enrichImportedBooks( books: List, importedBookIds: Set @@ -465,7 +475,7 @@ object DesktopFolderMetadataExtractor { return when (type) { FileType.PDF -> Color(156, 65, 70) FileType.EPUB -> Color(0, 108, 76) - FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73) + FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(112, 93, 73) FileType.MD -> Color(83, 101, 120) FileType.HTML -> Color(122, 87, 42) FileType.TXT -> Color(74, 92, 112) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncFeedback.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncFeedback.kt new file mode 100644 index 0000000..3f241c9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderSyncFeedback.kt @@ -0,0 +1,16 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.SharedReaderScreenState + +internal fun desktopFolderSyncCompletedState( + state: SharedReaderScreenState, + message: String, + failedFolderCount: Int, + showBanner: Boolean +): SharedReaderScreenState { + return if (showBanner) { + state.withBanner(message, isError = failedFolderCount > 0) + } else { + state + } +} 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 30923d1..f3c8ec0 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt @@ -33,6 +33,7 @@ import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.WebSocket +import java.net.http.WebSocketHandshakeException import java.nio.ByteBuffer import java.util.Base64 import java.util.concurrent.CompletableFuture @@ -56,7 +57,7 @@ class DesktopGeminiCloudTtsAdapter( private val networkAccess: () -> Boolean = { true }, private val workerUrlProvider: () -> String = { "" }, private val authTokenProvider: suspend () -> String? = { null }, - private val useWorkerProvider: () -> Boolean = { false }, + private val useWorkerProvider: () -> Boolean = { true }, private val onWorkerUsageCompleted: suspend () -> Unit = {}, httpClient: HttpClient? = null, private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot()) @@ -75,14 +76,15 @@ class DesktopGeminiCloudTtsAdapter( @Volatile private var activePlayer: DesktopStreamingPcmPlayer? = null + val isPlaybackActive: Boolean + get() = activePlayer != null || activeWebSocket != null || activeLine != null + override val isAvailable: Boolean get() { val settings = settingsProvider().sanitized() - return networkAccess() && if (useWorkerProvider()) { - settings.serverBackedCloudTts && workerUrlProvider().isNotBlank() - } else { - settings.isByokCloudTtsAvailable - } + return networkAccess() && + (settings.isByokCloudTtsAvailable || + (useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank())) } override suspend fun speak(text: String) { @@ -125,6 +127,12 @@ class DesktopGeminiCloudTtsAdapter( ) } .filter { it.text.isNotBlank() } + logDesktopTtsStartTrace { + "event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + + "inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " + + "inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\"" + } logDesktopTts( "chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + "chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}" @@ -182,28 +190,27 @@ class DesktopGeminiCloudTtsAdapter( onChunkStart: suspend (Int) -> Unit ) = withContext(Dispatchers.IO) { val settings = settingsProvider().sanitized() - val useWorker = useWorkerProvider() - val authToken = if (useWorker) authTokenProvider() else null + val useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable val totalTextChars = chunks.sumOf { it.text.length } logDesktopTts( "stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " + - "available=${settings.isCloudTtsAvailable} worker=$useWorker" + "available=${settings.isCloudTtsAvailable} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker" ) 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( - if (useWorker) { - "Cloud TTS needs a signed-in account with credits." - } else { - "Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected." - } - ) + if (useWorker) { + if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) { + logDesktopTts("stream_blocked reason=server_backed_not_available") + throw IllegalStateException("Cloud TTS needs a signed-in account with credits.") + } + } else if (!settings.isByokCloudTtsAvailable) { + logDesktopTts("stream_blocked reason=byok_not_available") + throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.") } + val authToken = if (useWorker) authTokenProvider() else null if (useWorker && authToken.isNullOrBlank()) { logDesktopTts("stream_blocked reason=missing_auth_token") throw IllegalStateException("Sign in with Google to use cloud TTS.") @@ -220,6 +227,7 @@ class DesktopGeminiCloudTtsAdapter( val messageBuffer = StringBuilder() var webSocket: WebSocket? = null var activeTempCacheFile: File? = null + var workerGeneratedAudio = false fun handleMessage(message: String) { handleGeminiTtsMessage( @@ -329,7 +337,7 @@ class DesktopGeminiCloudTtsAdapter( .get(15, TimeUnit.SECONDS) }.getOrElse { error -> logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"") - throw error + throw IllegalStateException(desktopTtsConnectionMessage(error), error) } activeWebSocket = connectedWebSocket webSocket = connectedWebSocket @@ -361,6 +369,10 @@ class DesktopGeminiCloudTtsAdapter( currentTurnAudioBytesReceived.set(0) currentTurnComplete.set(turnComplete) logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}") + logDesktopTtsStartTrace { + "event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " + + "textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\"" + } withContext(callbackContext) { onChunkStart(index) } @@ -420,6 +432,10 @@ class DesktopGeminiCloudTtsAdapter( logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}") throw IllegalStateException("Cloud TTS returned no audio for a text chunk.") } + if (useWorker) { + workerGeneratedAudio = true + onWorkerUsageCompleted() + } activeCacheOutput.getAndSet(null)?.close() runCatching { patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt()) @@ -454,8 +470,15 @@ class DesktopGeminiCloudTtsAdapter( activeWebSocket = null activePlayer = null logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}") - if (useWorker) onWorkerUsageCompleted() + if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted() } catch (error: Throwable) { + if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) { + try { + onWorkerUsageCompleted() + } catch (_: Throwable) { + // Keep the original TTS failure as the visible error. + } + } currentTurnComplete.set(null) activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } } activeTempCacheFile?.delete() @@ -629,6 +652,39 @@ private fun ByteArray.upsample16BitMonoLe2x(): ByteArray { return output } +private fun desktopTtsConnectionMessage(error: Throwable): String { + val causes = generateSequence(error) { it.cause }.toList() + val handshake = causes.filterIsInstance().firstOrNull() + return when (handshake?.response?.statusCode()) { + 401 -> "Sign in again to use cloud TTS." + 402 -> "Out of credits. Pro and credits can only be purchased from the Android app." + 403 -> "Cloud TTS is unavailable for this account." + 405 -> "Cloud TTS is not configured for this desktop build." + 426 -> "Cloud TTS is not configured for this desktop build." + 502 -> "Cloud TTS service is temporarily unavailable." + else -> { + val details = causes + .joinToString(" ") { it.message.orEmpty() } + .trim() + when { + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) -> + "Out of credits. Pro and credits can only be purchased from the Android app." + details.contains("401") || details.contains("Unauthorized", ignoreCase = true) -> + "Sign in again to use cloud TTS." + else -> "Cloud TTS failed to connect." + } + } + } +} + +private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean { + val details = generateSequence(error) { it.cause } + .joinToString(" ") { it.message.orEmpty() } + return details.contains("Out of credits", ignoreCase = true) || + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || + details.contains("402", ignoreCase = true) +} + private class DesktopStreamingPcmPlayer( private val onLineChanged: (SourceDataLine?) -> Unit ) { @@ -742,7 +798,6 @@ private class DesktopStreamingPcmPlayer( openLine(24_000f) }.onFailure { secondError -> logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"") - secondError.printStackTrace() }.getOrElse { throw firstError } 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 0d60e2a..ff6bc2e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt @@ -3,18 +3,38 @@ package com.aryan.reader.desktop import com.aryan.reader.shared.SharedLibrarySnapshot import com.aryan.reader.shared.SharedLibrarySnapshotJson import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject class DesktopLibraryDatabase( private val databaseFile: File = defaultDatabaseFile() ) { fun load(): SharedLibrarySnapshot { - if (!databaseFile.exists()) return SharedLibrarySnapshot() - return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText()) + return loadFile(databaseFile) + ?: loadFile(backupFile()) + ?: SharedLibrarySnapshot() } fun save(snapshot: SharedLibrarySnapshot) { - databaseFile.parentFile?.mkdirs() - databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot)) + val encoded = SharedLibrarySnapshotJson.encode(snapshot) + databaseFile.writeTextAtomically(encoded) + runCatching { + backupFile().writeTextAtomically(encoded) + } + } + + private fun loadFile(file: File): SharedLibrarySnapshot? { + if (!file.isFile) return null + val raw = runCatching { file.readText() }.getOrNull() ?: return null + val isJsonObject = runCatching { + libraryDatabaseJson.parseToJsonElement(raw).jsonObject + }.isSuccess + if (!isJsonObject) return null + return SharedLibrarySnapshotJson.decodeOrEmpty(raw) + } + + private fun backupFile(): File { + return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak") } companion object { @@ -23,3 +43,5 @@ class DesktopLibraryDatabase( } } } + +private val libraryDatabaseJson = Json { ignoreUnknownKeys = true } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt index 086b58d..c7da3af 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt @@ -1,5 +1,6 @@ package com.aryan.reader.desktop +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,12 +20,14 @@ import androidx.compose.material3.MaterialTheme 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,7 +47,6 @@ import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reduce import com.aryan.reader.shared.ui.NonReaderLibraryTab import com.aryan.reader.shared.ui.SharedLibraryScreen -import com.aryan.reader.shared.ui.SharedShelvesScreen import com.aryan.reader.shared.ui.SharedStableOutlinedTextField import com.aryan.reader.shared.ui.readerString import java.io.File @@ -117,82 +119,62 @@ internal fun resolvedDesktopReaderSettings( @Composable internal fun DesktopReaderOpeningScreen( - opening: DesktopReaderOpening + opening: DesktopReaderOpening, + readerSettings: ReaderSettings? = null ) { + LaunchedEffect(opening.requestId) { + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_opening_screen_composed") + } + } + val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background + val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground Box( - modifier = Modifier.fillMaxSize().padding(32.dp), + modifier = Modifier + .fillMaxSize() + .background(background) + .padding(32.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - CircularProgressIndicator() + CircularProgressIndicator(color = foreground) Text( text = readerString("desktop_opening_title", "Opening %1\$s", opening.title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + color = foreground, textAlign = TextAlign.Center ) Text( text = opening.formatLabel, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = foreground.copy(alpha = 0.72f), textAlign = TextAlign.Center ) } } } -@Composable -internal fun HomeScreen( - state: SharedReaderScreenState, - selectedLibraryTab: NonReaderLibraryTab, - onLibraryTabChange: (NonReaderLibraryTab) -> Unit, - onStateChange: (SharedReaderScreenState) -> Unit, - onImportBooks: () -> Unit, - onImportFolder: () -> Unit, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onClearSelection: () -> Unit, - onRemoveSelected: () -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit, - onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit, - onSyncFolderMetadata: () -> Unit, - onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit -) { - LibraryScreen( - state = state, - selectedLibraryTab = selectedLibraryTab, - onLibraryTabChange = onLibraryTabChange, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onImportFolder = onImportFolder, - onRead = onRead, - onSelect = onSelect, - onClearSelection = onClearSelection, - onRemoveSelected = onRemoveSelected, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, - onSyncFolderMetadata = onSyncFolderMetadata, - onScanFolders = onScanFolders, - onTogglePinned = onTogglePinned - ) +private fun ReaderSettings.desktopOpeningBackgroundColor(): Color { + return backgroundColorArgb?.toDesktopOpeningComposeColor() + ?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) +} + +private fun ReaderSettings.desktopOpeningForegroundColor(): Color { + return textColorArgb?.toDesktopOpeningComposeColor() + ?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) +} + +private fun Long.toDesktopOpeningComposeColor(): Color { + val value = this and 0xFFFFFFFFL + val alpha = ((value shr 24) and 0xFF) / 255f + val red = ((value shr 16) and 0xFF) / 255f + val green = ((value shr 8) and 0xFF) / 255f + val blue = (value and 0xFF) / 255f + return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f) } @Composable @@ -209,12 +191,15 @@ internal fun LibraryScreen( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onCreateShelf: () -> Unit, + onCreateShelfWithBooks: (String, Set) -> Unit, onCreateSmartShelf: () -> Unit, onRenameShelf: (Shelf) -> Unit, onDeleteShelf: (Shelf) -> Unit, onRemoveFolder: (Shelf) -> Unit, onTagSelectedBooks: () -> Unit, onAddSelectedBooksToShelf: () -> Unit, + onAddBooksToShelf: (Set) -> Unit, + onManageShelfBooks: (Shelf) -> Unit, onImportFolder: () -> Unit, onSyncFolderMetadata: () -> Unit, onScanFolders: () -> Unit, @@ -233,12 +218,15 @@ internal fun LibraryScreen( onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, onCreateShelf = onCreateShelf, + onCreateShelfWithBooks = onCreateShelfWithBooks, onCreateSmartShelf = onCreateSmartShelf, onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, onRemoveFolder = onRemoveFolder, onTagSelectedBooks = onTagSelectedBooks, onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onAddBooksToShelf = onAddBooksToShelf, + onManageShelfBooks = onManageShelfBooks, onImportFolder = onImportFolder, onSyncFolderMetadata = onSyncFolderMetadata, onScanFolders = onScanFolders, @@ -248,39 +236,6 @@ internal fun LibraryScreen( ) } -@Composable -internal fun ShelvesScreen( - shelves: List, - selectedBookIds: Set, - pinnedBookIds: Set, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onTogglePinned: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit -) { - SharedShelvesScreen( - shelves = shelves, - selectedBookIds = selectedBookIds, - pinnedBookIds = pinnedBookIds, - onOpenBook = onRead, - onToggleSelection = onSelect, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder - ) -} - private data class DesktopSmartRuleDraft( val field: SmartField = SmartField.TITLE, val operator: SmartOperator = SmartOperator.CONTAINS, 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 d99fc9e..4c8d099 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt @@ -24,6 +24,7 @@ import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import com.aryan.reader.shared.pdf.SharedPdfRichTextLog import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer import com.aryan.reader.shared.toSharedFolderBookMetadata +import com.aryan.reader.shared.toStablePositionCfi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -46,7 +47,8 @@ data class DesktopLocalFolderSyncResult( val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(), val idMigrations: Map = emptyMap(), val removedBookIds: Set = emptySet(), - val failedFolders: List = emptyList() + val failedFolders: List = emptyList(), + val processedFolderUris: List = emptyList() ) object DesktopLocalFolderSync { @@ -56,10 +58,18 @@ object DesktopLocalFolderSync { if (!folder.isDirectory) return false return folder.walkTopDown() .onEnter { it == folder || it.shouldEnterSyncedFolder() } + .onFail { file, error -> + logDesktopFolderSync( + "folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " + + "error=${error.folderSyncSummary()}" + ) + } .any { file -> - file.isFile && - file.shouldSyncBookFile() && - SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes + runCatching { + file.isFile && + file.shouldSyncBookFile() && + SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes + }.getOrDefault(false) } } @@ -68,9 +78,11 @@ object DesktopLocalFolderSync { shelfRefs: List, targetFolder: File? = null, nowMillis: Long = System.currentTimeMillis(), - metadataOnly: Boolean = false + metadataOnly: Boolean = false, + extractMetadata: Boolean = true ): DesktopLocalFolderSyncResult { val requestedFolders = foldersToSync(state, targetFolder, nowMillis) + .filter { it.localSyncEnabled } val mode = if (metadataOnly) "metadata" else "full" logDesktopFolderSync( "sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + @@ -84,6 +96,7 @@ object DesktopLocalFolderSync { val allMigrations = linkedMapOf() val allRemovedBookIds = linkedSetOf() val failedFolders = mutableListOf() + val processedFolderUris = mutableListOf() requestedFolders.forEach { folder -> val root = File(folder.uriString) @@ -95,6 +108,7 @@ object DesktopLocalFolderSync { failedFolders += folder.name return@forEach } + processedFolderUris += folder.uriString logDesktopFolderSync( "folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + @@ -138,8 +152,27 @@ object DesktopLocalFolderSync { logDesktopFolderSync( "folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}" ) - importAnnotationSidecars(root, syncedBooks) - if (!metadataOnly) { + runCatching { + importAnnotationSidecars(root, syncedBooks) + }.onFailure { error -> + logDesktopFolderSync( + "annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + + "root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" + ) + } + syncedBooks.forEach { book -> + remoteMetadata[book.id]?.let { metadata -> + runCatching { + importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp) + }.onFailure { error -> + logDesktopFolderSync( + "metadata.bookmarks.importFailed book=${book.id} " + + "error=${error.folderSyncSummary()}" + ) + } + } + } + if (!metadataOnly && extractMetadata) { val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks( books = nextState.rawLibraryBooks, sourceFolder = folder.uriString @@ -173,7 +206,8 @@ object DesktopLocalFolderSync { metadataStats = totalMetadataStats, idMigrations = allMigrations, removedBookIds = allRemovedBookIds, - failedFolders = failedFolders + failedFolders = failedFolders, + processedFolderUris = processedFolderUris ) logDesktopFolderSync( "sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " + @@ -188,8 +222,13 @@ object DesktopLocalFolderSync { savePdfAnnotationSidecar(book) } + fun deleteSyncDataFolder(root: File): Boolean { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) + return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively() + } + fun saveBookMetadata(book: BookItem) { - val metadata = book.toSharedFolderBookMetadata() + val metadata = book.toDesktopFolderBookMetadata() if (metadata == null) { logDesktopFolderSync( "metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " + @@ -242,11 +281,9 @@ object DesktopLocalFolderSync { val data = buildMap { if (annotationFile.isFile) { val annotationJson = annotationFile.readText().trim() - val annotations = SharedPdfAnnotationSerializer.decode(annotationJson) - put( - SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, - SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) - ) + desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations -> + put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) + } } if (bookmarkFile.isFile) { val bookmarksJson = bookmarkFile.readText().trim() @@ -267,7 +304,7 @@ object DesktopLocalFolderSync { "file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " + "textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}" ) - put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument)) + desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) } } } } @@ -279,9 +316,9 @@ object DesktopLocalFolderSync { return } val timestamp = maxOf( - annotationFile.lastModifiedIfFile(), + annotationFile.lastModifiedIfSyncableAnnotations(), bookmarkFile.lastModifiedIfFile(), - richTextFile.lastModifiedIfFile(), + richTextFile.lastModifiedIfSyncableRichText(), System.currentTimeMillis() ) val dataJson = desktopFolderSyncJson.encodeToString( @@ -329,7 +366,15 @@ object DesktopLocalFolderSync { val rootPath = root.toPath().toAbsolutePath().normalize() return root.walkTopDown() .onEnter { it == root || it.shouldEnterSyncedFolder() } - .filter { it.isFile && it.shouldSyncBookFile() } + .onFail { file, error -> + logDesktopFolderSync( + "folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " + + "path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" + ) + } + .filter { file -> + runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false) + } .mapNotNull { file -> val type = SharedFileCapabilities.fileTypeForName(file.name) .takeIf { it in desktopSyncableTypes } @@ -344,8 +389,8 @@ object DesktopLocalFolderSync { sourceFolder = sourceFolder, relativePath = relativePath, type = type, - size = file.length(), - lastModified = file.lastModified() + size = runCatching { file.length() }.getOrDefault(0L), + lastModified = runCatching { file.lastModified() }.getOrDefault(0L) ) } .toList() @@ -559,13 +604,18 @@ object DesktopLocalFolderSync { } if (sidecar.data.hasPdfAnnotationPayload()) { val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data) - 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()}\"" - ) + if (annotations.isEmpty()) { + if (annotationFile.isFile) annotationFile.delete() + logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}") + } else { + 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() @@ -582,13 +632,18 @@ object DesktopLocalFolderSync { "textLen=${richDocument.text.length} spans=${richDocument.spans.size} " + "file=\"${richTextFile.absolutePath.richSyncPreview()}\"" ) - 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()}\"" - ) + if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { + if (richTextFile.isFile) richTextFile.delete() + logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}") + } else { + 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()}\"" + ) + } } } } @@ -814,6 +869,56 @@ private fun File.lastModifiedIfFile(): Long { return if (isFile) lastModified() else 0L } +private fun File.hasSyncablePdfAnnotations(): Boolean { + return isFile && desktopPdfAnnotationElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableAnnotations(): Long { + return if (hasSyncablePdfAnnotations()) lastModified() else 0L +} + +private fun File.hasSyncablePdfRichText(): Boolean { + return isFile && desktopPdfRichTextElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableRichText(): Long { + return if (hasSyncablePdfRichText()) lastModified() else 0L +} + +private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? { + val base = toSharedFolderBookMetadata() + val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this) + if (base == null && pdfBookmarksJson == null) return null + + val timestamp = maxOf( + base?.lastModifiedTimestamp ?: 0L, + desktopPdfBookmarkMetadataTimestamp(this), + this.timestamp + ) + + return (base ?: SharedFolderBookMetadata( + bookId = id, + title = null, + author = null, + displayName = displayName, + type = type.name, + lastChapterIndex = readerPosition?.chapterIndex, + lastPage = readerPosition?.pageIndex ?: lastPageIndex, + lastPositionCfi = readerPosition?.toStablePositionCfi(), + progressPercentage = progressPercentage ?: 0f, + isRecent = isRecent, + lastModifiedTimestamp = timestamp, + bookmarksJson = null, + locatorBlockIndex = readerPosition?.blockIndex, + locatorCharOffset = readerPosition?.charOffset, + customName = null, + highlightsJson = null + )).copy( + lastModifiedTimestamp = timestamp, + bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson + ) +} + private fun uniqueFolderSyncTempName(baseName: String): String { val stem = baseName.removeSuffix(".tmp") val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}" diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt index 6787c48..b907520 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPaidAiAdapter.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonPrimitive import java.io.InputStream import java.net.HttpURLConnection import java.net.URL +import kotlin.math.ceil internal class DesktopPaidAiAdapter( private val config: DesktopCloudConfig, @@ -25,7 +26,7 @@ internal class DesktopPaidAiAdapter( private val currentSignedIn: () -> Boolean, private val currentIsProUser: () -> Boolean, private val currentCredits: () -> Int, - private val onUsageCompleted: suspend () -> Unit = {} + private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {} ) : AiAdapter { override val isAvailable: Boolean get() = networkAccess() && @@ -196,13 +197,18 @@ internal class DesktopPaidAiAdapter( val responseCode = connection.responseCode val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream if (responseCode in 200..299) { - val parsed = readWorkerStream(stream, onChunk, onUsageReceived) + val parsed = readWorkerStream( + stream = stream, + onChunk = onChunk, + onUsageReceived = onUsageReceived, + onUsageReported = onUsageReported + ) if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.") - onUsageCompleted() return@runCatching parsed } val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) { + onUsageReported(DesktopPaidAiUsage()) throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.") } if (connection.responseCode == 401) { @@ -222,6 +228,19 @@ internal class DesktopPaidAiAdapter( } } +internal data class DesktopPaidAiUsage( + val cost: Double? = null, + val freeRemaining: Int? = null +) + +internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int { + val deducted = cost + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { ceil(it).toInt() } + ?: return currentCredits + return (currentCredits - deducted).coerceAtLeast(0) +} + private data class DesktopPaidAiResponse( val text: String, val cost: Double? = null, @@ -233,18 +252,35 @@ private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true } private fun readWorkerStream( stream: InputStream?, onChunk: (String) -> Unit, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, + onUsageReported: (DesktopPaidAiUsage) -> Unit ): DesktopPaidAiResponse { val output = StringBuilder() var cost: Double? = null var freeRemaining: Int? = null + var paidUsageReported = false + var freeUsageReported = false stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines -> lines.forEach { line -> - val parsed = parseWorkerStreamLine(line) ?: return@forEach + val parsed = try { + parseWorkerStreamLine(line) + } catch (error: IllegalStateException) { + if (desktopPaidAiShouldRefreshAccountAfterError(error)) { + onUsageReported(DesktopPaidAiUsage()) + } + throw error + } ?: return@forEach parsed.cost?.let { cost = it } parsed.freeRemaining?.let { freeRemaining = it } if (parsed.cost != null || parsed.freeRemaining != null) { onUsageReceived(parsed.cost, parsed.freeRemaining) + if (parsed.cost != null && !paidUsageReported) { + paidUsageReported = true + onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining)) + } else if (parsed.freeRemaining != null && !freeUsageReported) { + freeUsageReported = true + onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining)) + } } parsed.chunk?.let { chunk -> output.append(chunk) @@ -275,6 +311,14 @@ private data class DesktopPaidAiStreamLine( val freeRemaining: Int? = null ) +private fun desktopPaidAiShouldRefreshAccountAfterError(error: Throwable): Boolean { + val details = generateSequence(error) { it.cause } + .joinToString(" ") { it.message.orEmpty() } + return details.contains("Out of credits", ignoreCase = true) || + details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || + details.contains("402", ignoreCase = true) +} + private fun workerErrorMessage(errorBody: String): String? { return when { errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app." diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt index 56d7302..b5b7a75 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt @@ -9,14 +9,17 @@ 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.fillMaxHeight 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Search @@ -40,24 +43,32 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontStyle 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.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import com.aryan.reader.shared.pdf.pdfCommentChildren import com.aryan.reader.shared.pdf.sharedPdfStrokePercent import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange import com.aryan.reader.shared.pdf.sharedPdfTextStyle +import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments +import com.aryan.reader.shared.pdf.withoutPdfCommentThread import com.aryan.reader.shared.pdf.withSharedPdfTextStyle import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog import com.aryan.reader.shared.ui.SharedPdfTextStyleControls import com.aryan.reader.shared.ui.SharedStableOutlinedTextField import com.aryan.reader.shared.ui.readerString +import java.text.DateFormat +import java.util.Date +import java.util.UUID internal val DesktopPdfAnnotationTools = listOf( PdfInkTool.PEN, @@ -69,6 +80,11 @@ internal val DesktopPdfAnnotationTools = listOf( PdfInkTool.ERASER ) +private enum class DesktopPdfAnnotationSheetSection { + NOTE, + COMMENTS +} + @Composable internal fun DesktopPdfAnnotationEditor( annotation: SharedPdfAnnotation, @@ -82,12 +98,51 @@ internal fun DesktopPdfAnnotationEditor( onSearch: () -> Unit ) { val highlighterColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors } var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } + var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) { + mutableStateOf>(emptyList()) + } val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || annotation.tool == PdfInkTool.HIGHLIGHTER || annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) } + var commentText by remember(annotation.id) { mutableStateOf("") } + var replyTargetId by remember(annotation.id) { mutableStateOf(null) } + var editingCommentId by remember(annotation.id) { mutableStateOf(null) } + var commentAuthor by remember(annotation.id) { + mutableStateOf( + annotation.comments + .lastOrNull { it.author.isNotBlank() } + ?.author + ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR + ) + } + + fun updateComments(nextComments: List) { + onUpdate(annotation.copy(comments = nextComments)) + } + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { highlighterColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = highlighterColors + } + editingHighlighterSlot = slotIndex + } Surface( color = MaterialTheme.colorScheme.surface, @@ -127,7 +182,7 @@ internal fun DesktopPdfAnnotationEditor( ) Text( "\"${annotation.text}\"", - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), maxLines = 4, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), @@ -185,12 +240,7 @@ internal fun DesktopPdfAnnotationEditor( modifier = Modifier .size(26.dp) .clickable { - val nextColor = if (isHighlighterAnnotation) { - SharedPdfAndroidHighlightColors.nearestArgb(argb) - } else { - argb - } - onUpdate(annotation.copy(colorArgb = nextColor)) + onUpdate(annotation.copy(colorArgb = argb)) }, color = Color(argb), shape = RoundedCornerShape(13.dp), @@ -217,24 +267,103 @@ internal fun DesktopPdfAnnotationEditor( ) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) .clickable { - editingHighlighterSlot = highlighterColors - .indexOf(annotation.colorArgb) - .takeIf { it >= 0 } - ?: 0 + openHighlighterEditor( + 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(readerString("label_note", "Note")) }, - minLines = 3, - maxLines = 5, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - selectionKey = annotation.id + DesktopPdfAnnotationSheetTabs( + selectedSection = selectedSection, + commentCount = annotation.comments.count { it.contents.isNotBlank() }, + onSectionChange = { selectedSection = it } ) + if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) { + SharedStableOutlinedTextField( + value = annotation.note.orEmpty(), + onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, + label = { Text(readerString("label_note", "Note")) }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = annotation.id + ) + } else { + DesktopPdfHighlightCommentsEditor( + comments = annotation.comments, + commentText = commentText, + commentAuthor = commentAuthor, + replyTargetId = replyTargetId, + editingCommentId = editingCommentId, + onCommentTextChange = { commentText = it }, + onCommentAuthorChange = { commentAuthor = it }, + onReply = { comment -> + editingCommentId = null + replyTargetId = comment.id + commentText = "" + }, + onCancelReply = { replyTargetId = null }, + onEdit = { comment -> + editingCommentId = comment.id + replyTargetId = null + commentText = comment.contents + commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + }, + onCancelEdit = { + editingCommentId = null + commentText = "" + }, + onDelete = { comment -> + val nextComments = annotation.comments.withoutPdfCommentThread(comment.id) + updateComments(nextComments) + if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { + replyTargetId = null + } + if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) { + editingCommentId = null + commentText = "" + } + }, + onAddComment = { + val contents = commentText.trim() + if (contents.isNotBlank()) { + val now = System.currentTimeMillis() + val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + val nextComments = if (editingCommentId != null) { + annotation.comments.map { comment -> + if (comment.id == editingCommentId) { + comment.copy( + author = author, + contents = contents, + modifiedAt = now + ) + } else { + comment + } + } + } else { + annotation.comments + SharedPdfAnnotationComment( + id = UUID.randomUUID().toString(), + parentId = replyTargetId, + author = author, + contents = contents, + createdAt = now, + modifiedAt = now + ) + } + updateComments(nextComments) + commentText = "" + replyTargetId = null + editingCommentId = null + } + } + ) + } } if (annotation.kind == PdfAnnotationKind.INK) { val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() @@ -261,23 +390,29 @@ internal fun DesktopPdfAnnotationEditor( } } editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex) - val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f) + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } SharedHsvColorPickerDialog( initialColor = initialColor, title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), onDismiss = { editingHighlighterSlot = null }, onSave = { color -> val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb) + val nextColors = updateHighlighterDraft(slot, color) onHighlighterPaletteChange( - SharedPdfHighlighterPalette(highlighterColors).withColorAt( - slotIndex = slot, - colorArgb = nextArgb - ) + SharedPdfHighlighterPalette(nextColors).sanitized() ) - onUpdate(annotation.copy(colorArgb = syncedArgb)) + onUpdate(annotation.copy(colorArgb = nextArgb)) editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) } ) { liveColor -> Row( @@ -285,7 +420,7 @@ internal fun DesktopPdfAnnotationEditor( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { - highlighterColors.forEachIndexed { index, argb -> + highlighterDraftColors().forEachIndexed { index, argb -> val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) Box( modifier = Modifier @@ -294,10 +429,14 @@ internal fun DesktopPdfAnnotationEditor( .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), + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, shape = RoundedCornerShape(21.dp) ) - .clickable { editingHighlighterSlot = index }, + .clickable { openHighlighterEditor(index) }, contentAlignment = Alignment.Center ) { Text( @@ -313,6 +452,265 @@ internal fun DesktopPdfAnnotationEditor( } } +@Composable +private fun DesktopPdfAnnotationSheetTabs( + selectedSection: DesktopPdfAnnotationSheetSection, + commentCount: Int, + onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(4.dp)) { + DesktopPdfAnnotationSheetTab( + label = readerString("label_note", "Note"), + selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) } + ) + DesktopPdfAnnotationSheetTab( + label = "${readerString("label_comments", "Comments")} ($commentCount)", + selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) } + ) + } + } +} + +@Composable +private fun DesktopPdfAnnotationSheetTab( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + modifier = modifier + .height(40.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentsEditor( + comments: List, + commentText: String, + commentAuthor: String, + replyTargetId: String?, + editingCommentId: String?, + onCommentTextChange: (String) -> Unit, + onCommentAuthorChange: (String) -> Unit, + onReply: (SharedPdfAnnotationComment) -> Unit, + onCancelReply: () -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onCancelEdit: () -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit, + onAddComment: () -> Unit +) { + val visibleComments = comments.visiblePdfAnnotationComments() + val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } + val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + ) { + DesktopPdfHighlightCommentThread( + comments = visibleComments, + parentId = null, + depth = 0, + visitedIds = emptySet(), + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } + + if (editingComment != null || replyTarget != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (editingComment != null) { + readerString("label_editing_comment", "Editing comment") + } else { + readerString( + "label_replying_to", + "Replying to %1\$s", + replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() + ) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) { + Text(readerString("action_cancel", "Cancel")) + } + } + } + + SharedStableOutlinedTextField( + value = commentAuthor, + onValueChange = onCommentAuthorChange, + label = { Text(readerString("author", "Author")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Spacer(Modifier.height(8.dp)) + + SharedStableOutlinedTextField( + value = commentText, + onValueChange = onCommentTextChange, + placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), + minLines = 3, + maxLines = 4, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) { + Text( + readerString( + if (editingComment != null) "action_save_comment" else "action_add_comment", + if (editingComment != null) "Save Comment" else "Add Comment" + ) + ) + } + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentThread( + comments: List, + parentId: String?, + depth: Int, + visitedIds: Set, + onReply: (SharedPdfAnnotationComment) -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit +) { + comments.pdfCommentChildren(parentId).forEach { comment -> + if (comment.id in visitedIds) return@forEach + DesktopPdfHighlightCommentItem( + comment = comment, + depth = depth, + onReply = { onReply(comment) }, + onEdit = { onEdit(comment) }, + onDelete = { onDelete(comment) } + ) + DesktopPdfHighlightCommentThread( + comments = comments, + parentId = comment.id, + depth = depth + 1, + visitedIds = visitedIds + comment.id, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun DesktopPdfHighlightCommentItem( + comment: SharedPdfAnnotationComment, + depth: Int, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val indentSize = (depth * 16).dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentSize, top = 6.dp, bottom = 6.dp) + ) { + if (depth > 0) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp() + if (timestamp.isNotBlank()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = comment.contents, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row { + TextButton(onClick = onReply) { + Text(readerString("action_reply", "Reply")) + } + TextButton(onClick = onEdit) { + Text(readerString("label_edit", "Edit")) + } + TextButton(onClick = onDelete) { + Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } +} + +private fun Long.formatDesktopPdfCommentTimestamp(): String { + if (this <= 0L) return "" + return runCatching { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this)) + }.getOrDefault("") +} + @Composable private fun DesktopBottomSheetToolButton( icon: ImageVector, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt index 8c0c060..6277bf3 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt @@ -1,7 +1,6 @@ package com.aryan.reader.desktop import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,12 +19,17 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.aryan.reader.shared.BuiltInPdfReaderThemes import com.aryan.reader.shared.PdfDisplayMode @@ -33,9 +37,11 @@ import com.aryan.reader.shared.ReaderTheme import com.aryan.reader.shared.reader.ReaderSettings internal enum class DesktopPdfInspectorTab(val title: String) { - VIEW("View"), + APPEARANCE("Appearance"), + APP_THEME("App theme"), + VISUAL("Visual"), MARKUP("Markup"), - ASSIST("Assist") + TTS("TTS") } internal data class DesktopPdfThemeStyle( @@ -56,15 +62,25 @@ internal fun DesktopPdfThemedPageImage( modifier: Modifier = Modifier ) { Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { - Image( - bitmap = bitmap, - contentDescription = contentDescription, - colorFilter = themeStyle.colorFilter, - modifier = Modifier.fillMaxSize() - ) val textureBitmap = themeStyle.textureBitmap - if (textureBitmap != null && themeStyle.textureAlpha > 0f) { - Canvas(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .semantics { this.contentDescription = contentDescription } + ) { + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(bitmap.width, bitmap.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize( + size.width.toInt().coerceAtLeast(1), + size.height.toInt().coerceAtLeast(1) + ), + colorFilter = themeStyle.colorFilter, + filterQuality = FilterQuality.High + ) + if (textureBitmap != null && themeStyle.textureAlpha > 0f) { drawRect( brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), size = size, @@ -77,7 +93,7 @@ internal fun DesktopPdfThemedPageImage( } internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { - val defaults = ReaderSettings(themeId = "no_theme") + val defaults = DesktopDefaultPdfReaderSettings val settings = this ?: defaults val themeId = settings.themeId val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt index 1040fff..909c59e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt @@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.ZoomOut 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.Surface @@ -49,6 +48,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -56,6 +56,7 @@ import androidx.compose.ui.zIndex import com.aryan.reader.shared.SearchHighlightMode import com.aryan.reader.shared.pdf.SharedPdfSearchResult import com.aryan.reader.shared.ui.ReaderMinimalSlider +import com.aryan.reader.shared.ui.ReaderTooltipIconButton import com.aryan.reader.shared.ui.SharedStableOutlinedTextField import com.aryan.reader.shared.ui.readerString import kotlinx.coroutines.delay @@ -82,12 +83,11 @@ internal fun DesktopPdfFullscreenBottomChrome( val chromeBackground = MaterialTheme.colorScheme.surfaceVariant val chromeContent = MaterialTheme.colorScheme.onSurface val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = MaterialTheme.colorScheme.surfaceVariant + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) 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), + .fillMaxWidth(), + shape = RoundedCornerShape(0.dp), color = chromeBackground, contentColor = chromeContent, tonalElevation = 0.dp, @@ -113,7 +113,11 @@ internal fun DesktopPdfFullscreenBottomChrome( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onPrevious, enabled = canGoPrevious) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { Icon( Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_page", "Previous page"), @@ -136,7 +140,11 @@ internal fun DesktopPdfFullscreenBottomChrome( thumbColor = sliderActive, modifier = Modifier.weight(1f) ) - IconButton(onClick = onNext, enabled = canGoNext) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { Icon( Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_page", "Next page"), @@ -171,10 +179,10 @@ internal fun DesktopPdfBottomChrome( val chromeBackground = MaterialTheme.colorScheme.surfaceVariant val chromeContent = MaterialTheme.colorScheme.onSurface val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = MaterialTheme.colorScheme.surfaceVariant + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(6.dp), + shape = RoundedCornerShape(0.dp), color = chromeBackground, contentColor = chromeContent, tonalElevation = 0.dp, @@ -199,7 +207,11 @@ internal fun DesktopPdfBottomChrome( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onPrevious, enabled = canGoPrevious) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { Icon( Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_page", "Previous page"), @@ -230,7 +242,11 @@ internal fun DesktopPdfBottomChrome( style = MaterialTheme.typography.labelSmall, color = chromeContent.copy(alpha = 0.72f) ) - IconButton(onClick = onNext, enabled = canGoNext) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { Icon( Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_page", "Next page"), @@ -298,7 +314,7 @@ internal fun DesktopPdfSearchTopBar( Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(6.dp), + shape = RoundedCornerShape(0.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp ) { @@ -307,7 +323,11 @@ internal fun DesktopPdfSearchTopBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"), + onClick = onClose, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search")) } SharedStableOutlinedTextField( @@ -318,7 +338,10 @@ internal fun DesktopPdfSearchTopBar( modifier = Modifier.weight(1f).focusRequester(focusRequester), trailingIcon = if (query.isNotEmpty()) { { - IconButton(onClick = { onQueryChange("") }) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"), + onClick = { onQueryChange("") } + ) { Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search")) } } @@ -327,7 +350,16 @@ internal fun DesktopPdfSearchTopBar( }, selectionKey = "desktop-pdf-search" ) - IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) { + val resultsTooltip = if (showResultsPanel) { + readerString("tooltip_hide_results_desc", "Collapse the search results panel") + } else { + readerString("tooltip_show_results_desc", "Expand the panel to see all search matches") + } + ReaderTooltipIconButton( + tooltip = resultsTooltip, + onClick = onToggleResults, + modifier = Modifier.size(36.dp) + ) { Icon( if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = if (showResultsPanel) { @@ -507,7 +539,15 @@ private fun DesktopPdfSearchNavigationPill( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = if (highlightMode == SearchHighlightMode.ALL) { + readerString("desktop_show_current_match_only", "Show current match only") + } else { + readerString("desktop_show_all_search_matches", "Show all search matches") + }, + onClick = onToggleHighlightMode, + modifier = Modifier.size(36.dp) + ) { Icon( if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"), @@ -518,7 +558,12 @@ private fun DesktopPdfSearchNavigationPill( } ) } - IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"), + onClick = onPrevious, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result")) } Text( @@ -531,7 +576,12 @@ private fun DesktopPdfSearchNavigationPill( fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) ) - IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"), + onClick = onNext, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result")) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt index dbef1b4..87e360f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfInspectorUi.kt @@ -5,50 +5,46 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.width +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Tab 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.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.aryan.reader.shared.BuiltInPdfReaderThemes import com.aryan.reader.shared.PdfDisplayMode import com.aryan.reader.shared.ReaderAiByokSettings -import com.aryan.reader.shared.ReaderAutoScrollState import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderTtsReadScope +import com.aryan.reader.shared.ReaderTheme import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfSpreadLayout -import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import com.aryan.reader.shared.pdf.SharedPdfRichTextController import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig @@ -56,8 +52,6 @@ import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle import com.aryan.reader.shared.reader.ReaderPageSpreadMode import com.aryan.reader.shared.reader.ReaderSettings -import com.aryan.reader.shared.ui.ReaderMinimalSlider -import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock import com.aryan.reader.shared.ui.SharedReaderThemeControls @@ -68,131 +62,93 @@ import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll @Composable internal fun DesktopPdfInspectorPanel( document: DesktopPdfDocument, - pageIndex: Int, displayMode: PdfDisplayMode, pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, customTextureIds: List, onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, onReaderSettingsChange: (ReaderSettings) -> Unit, - zoomControlScale: Float, - zoomSpec: PdfZoomSpec, - isTextSelectionMode: Boolean, selectedTool: PdfInkTool, isRichTextMode: Boolean, - selectedColor: Int, - strokeWidth: Float, - pdfHighlighterColors: List, pdfHighlighterPalette: SharedPdfHighlighterPalette, - isHighlighterSnapEnabled: Boolean, effectiveTextStyleConfig: SharedPdfTextStyleConfig, richTextController: SharedPdfRichTextController, pdfExtrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, ttsReplacementPreferences: ReaderTtsReplacementPreferences, - pageText: () -> String, onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onZoomOut: () -> Unit, - onZoomIn: () -> Unit, - onZoomChange: (Float) -> Unit, - onSelectPanMode: () -> Unit, - onTextSelectionModeToggle: () -> Unit, onRichTextModeToggle: () -> Unit, - onToolSelected: (PdfInkTool) -> Unit, - onColorSelected: (Int) -> Unit, - onStrokeWidthChange: (Float) -> Unit, - onUndoPage: () -> Unit, - onClearPage: () -> Unit, - onHighlighterSnapChange: (Boolean) -> Unit, onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { - var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) } - val viewInspectorListState = rememberLazyListState() + val inspectorTabs = remember(appThemeControls != null) { + desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null) + } + var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) } + LaunchedEffect(inspectorTabs) { + if (selectedPdfInspectorTab !in inspectorTabs) { + selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs } + ?: inspectorTabs.first() + } + } + val appThemeInspectorListState = rememberLazyListState() + val appearanceInspectorListState = rememberLazyListState() + val visualInspectorListState = rememberLazyListState() val markupInspectorListState = rememberLazyListState() - val assistInspectorListState = rememberLazyListState() + val ttsInspectorListState = rememberLazyListState() val pdfInspectorListState = when (selectedPdfInspectorTab) { - DesktopPdfInspectorTab.VIEW -> viewInspectorListState + DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState + DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState + DesktopPdfInspectorTab.VISUAL -> visualInspectorListState DesktopPdfInspectorTab.MARKUP -> markupInspectorListState - DesktopPdfInspectorTab.ASSIST -> assistInspectorListState + DesktopPdfInspectorTab.TTS -> ttsInspectorListState } Surface( - modifier = Modifier - .width(340.dp) - .fillMaxHeight(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(0.dp) ) { Column(modifier = Modifier.fillMaxSize()) { DesktopPdfInspectorHeader( + tabs = inspectorTabs, selectedTab = selectedPdfInspectorTab, onTabSelected = { selectedPdfInspectorTab = it } ) HorizontalDivider() DesktopPdfInspectorContent( document = document, - pageIndex = pageIndex, displayMode = displayMode, pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, customTextureIds = customTextureIds, onImportTexture = onImportTexture, onReaderSettingsChange = onReaderSettingsChange, - zoomControlScale = zoomControlScale, - zoomSpec = zoomSpec, - isTextSelectionMode = isTextSelectionMode, selectedTool = selectedTool, isRichTextMode = isRichTextMode, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - pdfHighlighterColors = pdfHighlighterColors, pdfHighlighterPalette = pdfHighlighterPalette, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, effectiveTextStyleConfig = effectiveTextStyleConfig, richTextController = richTextController, pdfExtrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, ttsReplacementPreferences = ttsReplacementPreferences, - pageText = pageText, selectedTab = selectedPdfInspectorTab, listState = pdfInspectorListState, onDisplayModeSelected = onDisplayModeSelected, - onPageScrub = onPageScrub, - onPageScrubFinished = onPageScrubFinished, - onZoomOut = onZoomOut, - onZoomIn = onZoomIn, - onZoomChange = onZoomChange, - onSelectPanMode = onSelectPanMode, - onTextSelectionModeToggle = onTextSelectionModeToggle, onRichTextModeToggle = onRichTextModeToggle, - onToolSelected = onToolSelected, - onColorSelected = onColorSelected, - onStrokeWidthChange = onStrokeWidthChange, - onUndoPage = onUndoPage, - onClearPage = onClearPage, - onHighlighterSnapChange = onHighlighterSnapChange, onHighlighterPaletteChange = onHighlighterPaletteChange, onTextStyleChange = onTextStyleChange, - onExternalLookup = onExternalLookup, - onOpenAiHub = onOpenAiHub, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange ) } @@ -201,32 +157,33 @@ internal fun DesktopPdfInspectorPanel( @Composable private fun DesktopPdfInspectorHeader( + tabs: List, selectedTab: DesktopPdfInspectorTab, onTabSelected: (DesktopPdfInspectorTab) -> Unit ) { - Column( - modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + ScrollableTabRow( + selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0), + edgePadding = 0.dp, + modifier = Modifier.fillMaxWidth() ) { - Text(readerString("desktop_pdf_tools", "PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - ScrollableTabRow( - selectedTabIndex = selectedTab.ordinal, - edgePadding = 0.dp, - modifier = Modifier.fillMaxWidth() - ) { - DesktopPdfInspectorTab.values().forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { onTabSelected(tab) }, - text = { - Text( - tab.localizedTitle(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } + tabs.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { onTabSelected(tab) }, + icon = { + Icon( + tab.icon(), + contentDescription = null + ) + }, + text = { + Text( + tab.localizedTitle(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) } } } @@ -234,56 +191,31 @@ private fun DesktopPdfInspectorHeader( @Composable private fun ColumnScope.DesktopPdfInspectorContent( document: DesktopPdfDocument, - pageIndex: Int, displayMode: PdfDisplayMode, pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)?, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, customTextureIds: List, onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, onReaderSettingsChange: (ReaderSettings) -> Unit, - zoomControlScale: Float, - zoomSpec: PdfZoomSpec, - isTextSelectionMode: Boolean, selectedTool: PdfInkTool, isRichTextMode: Boolean, - selectedColor: Int, - strokeWidth: Float, - pdfHighlighterColors: List, pdfHighlighterPalette: SharedPdfHighlighterPalette, - isHighlighterSnapEnabled: Boolean, effectiveTextStyleConfig: SharedPdfTextStyleConfig, richTextController: SharedPdfRichTextController, pdfExtrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, ttsReplacementPreferences: ReaderTtsReplacementPreferences, - pageText: () -> String, selectedTab: DesktopPdfInspectorTab, listState: LazyListState, onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onZoomOut: () -> Unit, - onZoomIn: () -> Unit, - onZoomChange: (Float) -> Unit, - onSelectPanMode: () -> Unit, - onTextSelectionModeToggle: () -> Unit, onRichTextModeToggle: () -> Unit, - onToolSelected: (PdfInkTool) -> Unit, - onColorSelected: (Int) -> Unit, - onStrokeWidthChange: (Float) -> Unit, - onUndoPage: () -> Unit, - onClearPage: () -> Unit, - onHighlighterSnapChange: (Boolean) -> Unit, onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)?, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) { @@ -296,14 +228,54 @@ private fun ColumnScope.DesktopPdfInspectorContent( verticalArrangement = Arrangement.spacedBy(14.dp) ) { when (selectedTab) { - DesktopPdfInspectorTab.VIEW -> { + DesktopPdfInspectorTab.APP_THEME -> { + appThemeControls?.let { controls -> + item { + controls() + } + } + } + DesktopPdfInspectorTab.APPEARANCE -> { item { - DesktopPdfInspectorSection(readerString("label_reading", "Reading")) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) { + SharedReaderThemeControls( + settings = pdfReaderSettings, + builtInThemes = BuiltInPdfReaderThemes, + customThemes = customReaderThemes, + onCustomThemesChange = onCustomReaderThemesChange, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + texturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, + onSettingsChange = onReaderSettingsChange + ) + } + } + } + DesktopPdfInspectorTab.VISUAL -> { + item { + DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { FilterChip( - selected = displayMode == PdfDisplayMode.PAGINATION, - onClick = { onDisplayModeSelected(PdfDisplayMode.PAGINATION) }, - label = { Text(readerString("desktop_page", "Page")) } + selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) } ) FilterChip( selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, @@ -348,53 +320,11 @@ private fun ColumnScope.DesktopPdfInspectorContent( ) } } - } - } - item { - DesktopPdfInspectorSection(readerString("visual_options_progress_bar_position", "Position")) { - val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.pageRangeLabel(pageIndex, document.pageCount, pdfReaderSettings) - } else { - "${pageIndex + 1}" - } - Text( - if ('-' in pageRange) { - readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, document.pageCount) - } else { - readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, document.pageCount) - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (document.pageCount > 1) { - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(document.pageCount - 1).toFloat() - ) - } - } - } - item { - DesktopPdfInspectorSection(readerString("app_theme_appearance", "Appearance")) { - SharedReaderThemeControls( - settings = pdfReaderSettings, - builtInThemes = BuiltInPdfReaderThemes, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - onSettingsChange = onReaderSettingsChange - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Text( - readerString("visual_options_title", "Visual options"), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) DesktopPdfVisualOptionSwitch( title = readerString("visual_options_remove_page_gap", "Remove gap between pages"), description = readerString( "desktop_remove_gap_between_pages_desc", - "Applies to vertical reading mode." + "Applies to vertical reading and two-page spreads." ), checked = !pdfReaderSettings.pdfVerticalPageGapVisible, onCheckedChange = { removeGap -> @@ -418,43 +348,11 @@ private fun ColumnScope.DesktopPdfInspectorContent( ) } } - item { - DesktopPdfInspectorSection(readerString("desktop_zoom", "Zoom")) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onZoomOut) { - Icon(Icons.Default.ZoomOut, contentDescription = readerString("desktop_zoom_out", "Zoom out")) - } - Text( - "${(zoomControlScale * 100).toInt()}%", - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center - ) - IconButton(onClick = onZoomIn) { - Icon(Icons.Default.ZoomIn, contentDescription = readerString("desktop_zoom_in", "Zoom in")) - } - } - Slider( - value = zoomControlScale, - onValueChange = onZoomChange, - valueRange = zoomSpec.min..zoomSpec.max - ) - } - } } DesktopPdfInspectorTab.MARKUP -> { item { - DesktopPdfInspectorSection(readerString("desktop_interaction", "Interaction")) { + DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode, - onClick = onSelectPanMode, - label = { Text(readerString("desktop_pan", "Pan")) } - ) - FilterChip( - selected = isTextSelectionMode, - onClick = onTextSelectionModeToggle, - label = { Text(readerString("desktop_select_text", "Select text")) } - ) FilterChip( selected = isRichTextMode, onClick = onRichTextModeToggle, @@ -463,24 +361,6 @@ private fun ColumnScope.DesktopPdfInspectorContent( } } } - item { - DesktopPdfInspectorSection(readerString("desktop_annotation_tools", "Annotation tools")) { - SharedPdfAnnotationToolDock( - selectedTool = selectedTool, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - tools = DesktopPdfAnnotationTools, - highlighterPalette = pdfHighlighterColors, - onToolSelected = onToolSelected, - onColorSelected = onColorSelected, - onStrokeWidthChange = onStrokeWidthChange, - onUndo = onUndoPage, - onClearPage = onClearPage, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - onHighlighterSnapChange = onHighlighterSnapChange - ) - } - } item { DesktopPdfInspectorSection(readerString("desktop_highlighter_palette", "Highlighter palette")) { SharedPdfHighlighterPaletteEditor( @@ -510,21 +390,14 @@ private fun ColumnScope.DesktopPdfInspectorContent( } } } - DesktopPdfInspectorTab.ASSIST -> { + DesktopPdfInspectorTab.TTS -> { item { - DesktopPdfExtrasPanel( - pageText = pageText(), + DesktopPdfTtsPanel( extrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, - onExternalLookup = onExternalLookup, - onOpenAiHub = onOpenAiHub, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, onCloudTtsClearCache = onCloudTtsClearCache, - onAutoScrollChange = onAutoScrollChange, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, ttsReplacementPreferences = ttsReplacementPreferences, ttsReplacementBookId = document.path, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange @@ -543,8 +416,29 @@ private fun ColumnScope.DesktopPdfInspectorContent( @Composable private fun DesktopPdfInspectorTab.localizedTitle(): String { return when (this) { - DesktopPdfInspectorTab.VIEW -> readerString("desktop_view", "View") + DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme") + DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme") + DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual") DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup") - DesktopPdfInspectorTab.ASSIST -> readerString("desktop_assist", "Assist") + DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS") + } +} + +private fun DesktopPdfInspectorTab.icon(): ImageVector { + return when (this) { + DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette + DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette + DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune + DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit + DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp + } +} + +private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List { + return buildList { + add(DesktopPdfInspectorTab.APPEARANCE) + if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME) + add(DesktopPdfInspectorTab.VISUAL) + add(DesktopPdfInspectorTab.TTS) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt index b337a00..ca16662 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfKeyCommands.kt @@ -23,7 +23,8 @@ internal enum class DesktopPdfKeyCommand { internal fun KeyEvent.desktopPdfKeyCommandOrNull( fullscreen: Boolean, - editingText: Boolean + editingText: Boolean, + rightToLeftPagination: Boolean = false ): DesktopPdfKeyCommand? { if (type != KeyEventType.KeyDown) return null if (fullscreen && key == Key.Escape) { @@ -33,8 +34,16 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull( return null } return when { - key == Key.DirectionLeft -> DesktopPdfKeyCommand.PREVIOUS_PAGE - key == Key.DirectionRight -> DesktopPdfKeyCommand.NEXT_PAGE + key == Key.DirectionLeft -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + key == Key.DirectionRight -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE @@ -50,7 +59,8 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull( internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( fullscreen: Boolean, - editingText: Boolean + editingText: Boolean, + rightToLeftPagination: Boolean = false ): DesktopPdfKeyCommand? { if (id != AwtKeyEvent.KEY_PRESSED) return null if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { @@ -60,8 +70,16 @@ internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( return null } return when (keyCode) { - AwtKeyEvent.VK_LEFT -> DesktopPdfKeyCommand.PREVIOUS_PAGE - AwtKeyEvent.VK_RIGHT -> DesktopPdfKeyCommand.NEXT_PAGE + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt index b1ec704..0f5f716 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +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 @@ -38,9 +39,11 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface @@ -59,6 +62,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -66,7 +70,6 @@ import com.aryan.reader.shared.PdfTocEntry import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfBookmark -import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar import com.aryan.reader.shared.ui.readerString import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll @@ -199,27 +202,28 @@ internal fun desktopVisiblePdfTocEntries( return result } +internal fun desktopPdfSidebarHighlights(annotations: List): List { + return annotations + .filter { it.kind == PdfAnnotationKind.HIGHLIGHT } + .sortedBy { it.pageIndex } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DesktopPdfNavigationSidebar( document: DesktopPdfDocument, pageIndex: Int, - sortedAnnotations: List, - sortedEmbeddedAnnotations: List, + sortedHighlights: List, bookmarks: List, - selectedAnnotationId: String?, - selectedEmbeddedAnnotationId: String?, onPageSelected: (Int) -> Unit, onAnnotationOpened: (SharedPdfAnnotation) -> Unit, onAnnotationSelected: (SharedPdfAnnotation) -> Unit, - onAnnotationDeleted: (SharedPdfAnnotation) -> Unit, - onEmbeddedAnnotationOpened: (SharedPdfEmbeddedAnnotation) -> Unit, - onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit + onAnnotationDeleted: (SharedPdfAnnotation) -> Unit ) { val documentHandleId = document.handleId val tabs = listOf( readerString("desktop_toc", "TOC"), - readerString("tab_annotations", "Annotations"), + readerString("tab_highlights", "Highlights"), readerString("tab_bookmarks", "Bookmarks"), readerString("tab_pages", "Pages") ) @@ -344,180 +348,158 @@ internal fun DesktopPdfNavigationSidebar( } } 1 -> { - if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) { - DesktopPdfNavigationEmpty(readerString("desktop_no_annotations_yet", "No annotations yet")) + if (sortedHighlights.isEmpty()) { + DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights 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 { onAnnotationOpened(annotation) } - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - annotation.desktopLabel(), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - readerString("pdf_page_short", "Page %1\$d", 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 = readerString("desktop_annotation_options", "Annotation options")) - } - DropdownMenu( - expanded = annotationMenuExpandedFor == annotation, - onDismissRequest = { annotationMenuExpandedFor = null } - ) { - DropdownMenuItem( - text = { - Text( - if (annotation.note.isNullOrBlank() && - annotation.kind != PdfAnnotationKind.TEXT - ) { - readerString("menu_add_note", "Add note") - } else { - readerString("action_edit", "Edit") - } - ) - }, - onClick = { - annotationMenuExpandedFor = null - onAnnotationSelected(annotation) - } - ) - DropdownMenuItem( - text = { Text(readerString("action_delete", "Delete")) }, - onClick = { - annotationMenuExpandedFor = null - deleteAnnotationConfirmFor = annotation - } - ) - } - } - } - } - } - 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 { onEmbeddedAnnotationOpened(annotation) } - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - annotation.author.ifBlank { readerString("desktop_pdf_comment", "PDF comment") }, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - readerString("pdf_page_short", "Page %1\$d", 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 = readerString("desktop_comment_options", "Comment options")) - } - DropdownMenu( - expanded = embeddedAnnotationMenuExpandedFor == annotation, - onDismissRequest = { embeddedAnnotationMenuExpandedFor = null } - ) { - DropdownMenuItem( - text = { Text(readerString("desktop_open_comment", "Open comment")) }, - onClick = { - embeddedAnnotationMenuExpandedFor = null - onEmbeddedAnnotationSelected(annotation) - } - ) - } - } - } - } - } + val highlightsListState = rememberLazyListState() + var deleteHighlightConfirmFor by remember { mutableStateOf(null) } + var filterWithNotesOnly by remember { mutableStateOf(false) } + val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) { + if (filterWithNotesOnly) { + sortedHighlights.filter { !it.note.isNullOrBlank() } + } else { + sortedHighlights } - SharedReaderVerticalScrollbar( - listState = annotationsListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) } - deleteAnnotationConfirmFor?.let { annotation -> + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = !filterWithNotesOnly, + onClick = { filterWithNotesOnly = false }, + label = { Text(readerString("read_status_all", "All")) } + ) + FilterChip( + selected = filterWithNotesOnly, + onClick = { filterWithNotesOnly = true }, + label = { Text(readerString("filter_with_notes", "With notes")) } + ) + } + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = highlightsListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(highlightsListState) + .padding(end = 12.dp) + ) { + items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight -> + ListItem( + headlineContent = { + Text( + text = highlight.text.ifBlank { + readerString( + "msg_highlighted_section_default", + "Highlighted section" + ) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(12.dp) + .background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + highlight.note?.takeIf { it.isNotBlank() }?.let { note -> + Spacer(Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = note, + style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + trailingContent = { + Box { + var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) } + IconButton(onClick = { highlightMenuExpanded = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = readerString("content_desc_options", "Options") + ) + } + DropdownMenu( + expanded = highlightMenuExpanded, + onDismissRequest = { highlightMenuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text( + if (highlight.note.isNullOrBlank()) { + readerString("menu_add_note", "Add note") + } else { + readerString("menu_edit_note", "Edit note") + } + ) + }, + onClick = { + onAnnotationSelected(highlight) + highlightMenuExpanded = false + } + ) + DropdownMenuItem( + text = { Text(readerString("action_delete", "Delete")) }, + onClick = { + deleteHighlightConfirmFor = highlight + highlightMenuExpanded = false + } + ) + } + } + }, + modifier = Modifier.clickable { onAnnotationOpened(highlight) } + ) + HorizontalDivider() + } + } + SharedReaderVerticalScrollbar( + listState = highlightsListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + + deleteHighlightConfirmFor?.let { highlight -> AlertDialog( - onDismissRequest = { deleteAnnotationConfirmFor = null }, - title = { Text(readerString("desktop_delete_annotation_title", "Delete annotation?")) }, - text = { Text(readerString("desktop_delete_annotation_desc", "This removes the annotation from this PDF.")) }, + onDismissRequest = { deleteHighlightConfirmFor = null }, + title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) }, + text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) }, confirmButton = { TextButton( onClick = { - deleteAnnotationConfirmFor = null - onAnnotationDeleted(annotation) + onAnnotationDeleted(highlight) + deleteHighlightConfirmFor = null } ) { - Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) + Text(readerString("action_delete", "Delete")) } }, dismissButton = { - TextButton(onClick = { deleteAnnotationConfirmFor = null }) { + TextButton(onClick = { deleteHighlightConfirmFor = null }) { Text(readerString("action_cancel", "Cancel")) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt index df27031..11076f9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPage.kt @@ -1,5 +1,7 @@ package com.aryan.reader.desktop +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -67,6 +69,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +private const val DesktopVerticalPdfPageTurnAnimationMillis = 140 + @Composable internal fun DesktopVerticalPdfPage( document: DesktopPdfDocument, @@ -96,6 +100,8 @@ internal fun DesktopVerticalPdfPage( themeStyle: DesktopPdfThemeStyle, shouldRender: Boolean, zoomPreview: DesktopPdfZoomPreview?, + zoomPreviewAnchorPageRootOffset: Offset? = null, + zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null, zoomViewportRootOffset: Offset, showPageNumberOverlay: Boolean = true, onSelectPage: (Int) -> Unit, @@ -116,13 +122,16 @@ internal fun DesktopVerticalPdfPage( onTextDraftChanged: (String, IntSize) -> Unit, onTextDraftBoundsChanged: (PdfPageBounds) -> Unit, onPan: (Offset) -> Unit, + onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> }, onPagePositioned: (Int, Offset) -> Unit ) { val documentHandleId = document.handleId val density = LocalDensity.current - var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var renderError by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var isRendering by remember(documentHandleId, pageIndex) { mutableStateOf(true) } + var renderedPage by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } + var renderError by remember(documentHandleId) { mutableStateOf(null) } + var isRendering by remember(documentHandleId) { mutableStateOf(true) } var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) } var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) } var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } @@ -156,34 +165,73 @@ internal fun DesktopVerticalPdfPage( LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) { if (!shouldRender) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}" + } renderedPage = null + renderedPageIndex = null + renderedPageScale = null renderError = null isRendering = false clearInteractionState() return@LaunchedEffect } - val hasPageRender = renderedPage != null + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) + logPdfZoomSettle { + "item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " + + "hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } if (!hasPageRender) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null isRendering = true } renderError = null val pageSize = document.pageSizes.getOrNull(pageIndex) if (pageSize == null) { renderedPage = null + renderedPageIndex = null + renderedPageScale = null renderError = failedRenderMessage isRendering = false return@LaunchedEffect } + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " + + "safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + isRendering = false + return@LaunchedEffect + } + logPdfZoomSettle { + "item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender" + } delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L) isRendering = true - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + val renderStartedAt = System.currentTimeMillis() val result = withContext(Dispatchers.IO) { runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } } - result.getOrNull()?.let { renderedPage = it } + val renderElapsedMs = System.currentTimeMillis() - renderStartedAt + result.getOrNull()?.let { + renderedPage = it + renderedPageIndex = pageIndex + renderedPageScale = safeScale + } + val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) renderError = result.exceptionOrNull()?.message - ?: if (renderedPage == null) failedRenderMessage else null + ?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null isRendering = false + logPdfZoomSettle { + "item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}" + } } LaunchedEffect(isTextSelectionMode) { @@ -205,6 +253,8 @@ internal fun DesktopVerticalPdfPage( verticalArrangement = Arrangement.spacedBy(6.dp) ) { val pageSize = document.pageSizes.getOrNull(pageIndex) + val displayPageIndex = renderedPageIndex ?: pageIndex + val displayPageIsCurrent = displayPageIndex == pageIndex val placeholderScale = zoomSpec.clamp(scale) val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() } val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() } @@ -224,24 +274,60 @@ internal fun DesktopVerticalPdfPage( .size(placeholderWidthDp, placeholderHeightDp) .onGloballyPositioned { coordinates -> val rootOffset = coordinates.positionInRoot() + if (rootOffset != pageRootOffset) { + logPdfZoomSettle { + "item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}" + } + } pageRootOffset = rootOffset onPagePositioned(pageIndex, rootOffset) } - .onSizeChanged { pageCanvasSize = it } + .onSizeChanged { size -> + if (pageCanvasSize != size) { + logPdfZoomSettle { + "item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " + + "next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}" + } + } + pageCanvasSize = size + onPageSizeChanged(pageIndex, size) + } .desktopPdfDocumentZoomPreviewLayer( preview = zoomPreview, currentZoom = scale, viewportRootOffset = zoomViewportRootOffset, - pageRootOffset = pageRootOffset + pageRootOffset = pageRootOffset, + anchorPageRootOffset = zoomPreviewAnchorPageRootOffset, + scrollBounds = zoomPreviewScrollBounds ) .background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { - if (isRichTextMode) return@pointerInput + .pointerInput( + pageIndex, + displayPageIsCurrent, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=vertical_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { currentAnnotations.asReversed().firstOrNull { it.isDesktopTextSelectionHighlight && @@ -252,6 +338,10 @@ internal fun DesktopVerticalPdfPage( null } if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } onSelectPage(pageIndex) onAnnotationSelected(highlightHit) clearInteractionState() @@ -261,6 +351,10 @@ internal fun DesktopVerticalPdfPage( if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } logPdfLink( "tap_hit mode=vertical page=${pageIndex + 1} " + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + @@ -277,6 +371,10 @@ internal fun DesktopVerticalPdfPage( it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) } if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } onSelectPage(pageIndex) onEmbeddedAnnotationSelected(embeddedHit) clearInteractionState() @@ -285,7 +383,16 @@ internal fun DesktopVerticalPdfPage( currentTextSelection != null && selectionMenuOffset == null ) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } clearSelection() + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=none consumed=false" + } } } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { val selection = currentTextSelection @@ -304,33 +411,41 @@ internal 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, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "vertical_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=vertical_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" } - ) + 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 + .pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) { + return@pointerInput + } awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture @@ -371,9 +486,10 @@ internal fun DesktopVerticalPdfPage( isRichTextMode, pageCanvasSize, renderedPageWidth, - renderedPageHeight + renderedPageHeight, + displayPageIsCurrent ) { - if (renderedPageWidth > 0 && renderedPageHeight > 0) { + if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) { if (isRichTextMode) return@pointerInput if (isTextSelectionMode) { var latestSelectionDragPoint: Offset? = null @@ -638,8 +754,21 @@ internal fun DesktopVerticalPdfPage( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - renderedPage != null -> { + renderError != null && renderedPageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> { + val currentRenderedPageIndex = renderedPageIndex!! + Crossfade( + targetState = currentRenderedPageIndex, + animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis), + label = "DesktopVerticalPdfPage" + ) { pageIndex -> val pageRender = renderedPage!! + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { annotations .filter { it.pageIndex == pageIndex } @@ -806,6 +935,10 @@ internal fun DesktopVerticalPdfPage( .matchParentSize() .pointerInput(pageIndex, selectionMenuOffset) { detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } clearSelection() } } @@ -842,6 +975,7 @@ internal fun DesktopVerticalPdfPage( showSearch = externalLookupAvailable, onClear = ::clearSelection ) + } } isRendering -> CircularProgressIndicator() renderError != null -> Text( diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt index 8c2750b..2c84bc6 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt @@ -1,6 +1,11 @@ package com.aryan.reader.desktop +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.unit.IntSize import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.PdfInkTool @@ -9,11 +14,17 @@ import com.aryan.reader.shared.pdf.PdfPageBounds import com.aryan.reader.shared.pdf.PdfPagePoint import com.aryan.reader.shared.pdf.PdfSelectionGeometry import com.aryan.reader.shared.pdf.PdfTextCharBounds +import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfInkRenderer +import com.aryan.reader.shared.pdf.SharedPdfReaderAction +import com.aryan.reader.shared.pdf.SharedPdfReaderState import com.aryan.reader.shared.pdf.SharedPdfTextDraft +import com.aryan.reader.shared.pdf.reduce import com.aryan.reader.shared.ui.sharedPdfHitTest import com.aryan.reader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout internal val PdfInkTool.isDesktopHighlighter: Boolean get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND @@ -24,6 +35,24 @@ internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean rangeStartIndex != null && rangeEndIndex != null +internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded( + annotation: SharedPdfAnnotation, + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec) + return if (annotation.isDesktopTextSelectionHighlight) { + next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) + } else { + next + } +} + +internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed( + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) +} + internal fun List.withDesktopPdfDragPoint( point: Offset, canvasSize: IntSize, @@ -46,6 +75,95 @@ internal fun List.withDesktopPdfDragPoint( return this + nextPoint } +internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress( + source: String, + pageIndex: Int, + onLongPress: (Offset) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val secondaryDown = currentEvent.buttons.isSecondaryPressed + logPdfChromeTap { + "long_press_down source=$source page=${pageIndex + 1} " + + "x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " + + "downConsumed=${down.isConsumed} secondary=$secondaryDown" + } + if (down.isConsumed || secondaryDown) { + logPdfChromeTap { + "long_press_skip source=$source page=${pageIndex + 1} " + + "reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}" + } + return@awaitEachGesture + } + val pointerId = down.id + val start = down.position + var latestPosition = start + var canceledBeforeLongPress = false + var longPressReached = false + var cancelReason = "" + + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val event = awaitPointerEvent() + if (event.buttons.isSecondaryPressed) { + canceledBeforeLongPress = true + cancelReason = "secondary_button" + return@withTimeout + } + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null) { + canceledBeforeLongPress = true + cancelReason = "pointer_lost" + return@withTimeout + } + latestPosition = change.position + val distance = (latestPosition - start).getDistance() + when { + change.isConsumed -> { + canceledBeforeLongPress = true + cancelReason = "change_consumed" + return@withTimeout + } + change.changedToUp() || !change.pressed -> { + canceledBeforeLongPress = true + cancelReason = "up_before_long_press" + return@withTimeout + } + distance > viewConfiguration.touchSlop -> { + canceledBeforeLongPress = true + cancelReason = "moved distance=${distance.formatLogFloat()}" + return@withTimeout + } + } + } + } + } catch (_: TimeoutCancellationException) { + longPressReached = !canceledBeforeLongPress + } + + if (!longPressReached) { + logPdfChromeTap { + "long_press_cancel source=$source page=${pageIndex + 1} " + + "reason=${cancelReason.ifBlank { "unknown" }} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + return@awaitEachGesture + } + logPdfChromeTap { + "long_press_reached source=$source page=${pageIndex + 1} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + onLongPress(latestPosition) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture + change.consume() + if (change.changedToUp() || !change.pressed) return@awaitEachGesture + } + } +} + internal data class DesktopPdfCharHit( val index: Int, val source: String, @@ -318,7 +436,7 @@ private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { } internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L -internal const val DesktopPdfZoomCommitDebounceMillis = 180L +internal const val DesktopPdfZoomCommitDebounceMillis = 260L internal const val DesktopPdfZoomRenderDebounceMillis = 300L internal const val DesktopPdfViewportPersistDebounceMillis = 300L internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt index 2ca55c7..d99ac7d 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfReaderScreen.kt @@ -1,6 +1,8 @@ package com.aryan.reader.desktop import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,7 +77,6 @@ import com.aryan.reader.shared.PdfDisplayMode 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.ReaderCloudTtsState import com.aryan.reader.shared.ReaderExtrasState import com.aryan.reader.shared.ReaderExternalLookupAction @@ -83,6 +85,7 @@ import com.aryan.reader.shared.ReaderTtsPlanner import com.aryan.reader.shared.ReaderTtsProgress import com.aryan.reader.shared.ReaderTtsReadScope import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTheme import com.aryan.reader.shared.SaveMode import com.aryan.reader.shared.SearchHighlightMode import com.aryan.reader.shared.SharedFeaturePolicy @@ -97,7 +100,6 @@ import com.aryan.reader.shared.pdf.PdfSpreadLayout import com.aryan.reader.shared.pdf.PdfVisiblePageLayout 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.SharedPdfJumpHistory @@ -122,6 +124,7 @@ 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.ReaderSettings +import com.aryan.reader.shared.readerCloudTtsControlsModel import com.aryan.reader.shared.reduce import com.aryan.reader.shared.ui.ReaderWorkspaceFileActionState import com.aryan.reader.shared.ui.ReaderWorkspaceShell @@ -129,16 +132,19 @@ import com.aryan.reader.shared.ui.LocalSharedStringResolver import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay +import com.aryan.reader.shared.ui.SharedPdfInteractionDock import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput import com.aryan.reader.shared.ui.SharedPdfRichTextLayer import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay import com.aryan.reader.shared.ui.SharedPdfVerticalScrollbar +import com.aryan.reader.shared.ui.SharedReaderTtsOverlayControls import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel import com.aryan.reader.shared.ui.readerString import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest import com.aryan.reader.shared.ui.sharedPdfHitTest import com.aryan.reader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -153,6 +159,35 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.math.abs import kotlin.math.roundToInt +private val DesktopPdfReaderFullscreenFocusRetryDelaysMillis = longArrayOf(80L, 120L, 160L, 240L) +private const val DesktopPdfPaginationPageTurnAnimationMillis = 140 + +private data class DesktopPdfPaginatedPageDisplay( + val pageIndex: Int, + val render: DesktopPdfPageRender +) + +private data class DesktopPdfPendingPaginatedScrollRestore( + val requestId: Int, + val pageIndex: Int, + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfInitialPageIndex( + requestedPageIndex: Int, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPageIndex = requestedPageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPageIndex, pageCount, settings) + } else { + clampedPageIndex + } +} @Composable internal fun PdfReaderScreen( @@ -162,10 +197,13 @@ internal fun PdfReaderScreen( initialReaderSettings: ReaderSettings? = null, onReturnToLibrary: (() -> Unit)? = null, onFullscreenChange: (Boolean) -> Unit = {}, + appThemeControls: (@Composable () -> Unit)? = null, onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit, onReaderSettingsChange: (ReaderSettings) -> Unit = {}, pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + customReaderThemes: List = emptyList(), + onCustomReaderThemesChange: (List) -> Unit = {}, customTextureIds: List = emptyList(), onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, onLocalSidecarsChanged: () -> Unit = {}, @@ -179,6 +217,7 @@ internal fun PdfReaderScreen( showPaidCredits: Boolean = false, onAiByokSettingsChange: (ReaderAiByokSettings) -> Unit = {}, featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, + cloudTtsControlsAvailable: Boolean = true, onReaderAiEntitlementRequired: (ReaderAiFeature, String) -> Boolean = { _, _ -> false }, onCloudTtsEntitlementRequired: () -> Boolean = { false }, onPaidFeatureError: (String?) -> Unit = {}, @@ -192,20 +231,52 @@ internal fun PdfReaderScreen( return stringResolver.string(name, fallback, *args) } val zoomSpec = remember { DesktopPdfZoomSpec } - val restoredInitialViewport = remember(documentHandleId, initialViewport) { - initialViewport?.sanitized(document.pageCount, zoomSpec) + val initialDesktopPdfReaderSettings = remember(documentHandleId, initialReaderSettings) { + initialReaderSettings.toDesktopPdfReaderSettings() + } + val initialPdfDisplayMode = initialDesktopPdfReaderSettings.toDesktopPdfDisplayMode() + val restoredInitialViewport = remember( + documentHandleId, + initialViewport, + initialDesktopPdfReaderSettings, + initialPdfDisplayMode + ) { + initialViewport?.sanitized(document.pageCount, zoomSpec)?.let { viewport -> + viewport.copy( + pageIndex = desktopPdfInitialPageIndex( + requestedPageIndex = viewport.pageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) + ) + } + } + val initialPdfPageIndex = remember( + documentHandleId, + initialPageIndex, + restoredInitialViewport, + initialPdfDisplayMode, + initialDesktopPdfReaderSettings + ) { + desktopPdfInitialPageIndex( + requestedPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) } var pdfReaderSettings by remember(documentHandleId) { - mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings()) + mutableStateOf(initialDesktopPdfReaderSettings) } var pdfState by remember(documentHandleId) { mutableStateOf( SharedPdfReaderState.initial( pageCount = document.pageCount, - initialPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, + initialPageIndex = initialPdfPageIndex, zoomSpec = zoomSpec ).copy( - displayMode = restoredInitialViewport?.displayMode ?: DesktopDefaultPdfDisplayMode, + displayMode = initialPdfDisplayMode, zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default) ) ) @@ -219,11 +290,19 @@ internal fun PdfReaderScreen( val zoomAnchorJob = remember(documentHandleId) { AtomicReference(null) } val zoomCommitJob = remember(documentHandleId) { AtomicReference(null) } var pdfZoomPreview by remember(documentHandleId) { mutableStateOf(null) } + var pdfZoomSettleSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pdfNavigationScrollRestoreSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pendingPdfNavigationScrollRestore by remember(documentHandleId) { + mutableStateOf(null) + } var activeTextDraft by remember(documentHandleId) { mutableStateOf(null) } var textStyleConfig by remember(documentHandleId) { mutableStateOf(SharedPdfTextStyleConfig()) } var pageCanvasSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } var pdfZoomViewportRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + var pdfZoomViewportSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } var paginatedPageRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + val paginatedPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } + val paginatedPageCanvasSizes = remember(documentHandleId) { mutableStateMapOf() } val verticalPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf() } var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf>(emptyList()) } @@ -246,7 +325,7 @@ internal fun PdfReaderScreen( mutableStateOf( ReaderExtrasState( cloudTts = ReaderCloudTtsState( - isAvailable = aiByokSettings.isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) ) ) @@ -258,7 +337,7 @@ internal fun PdfReaderScreen( var dismissedPdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(null) } var pdfHubSummaryResult by remember(documentHandleId) { mutableStateOf(null) } var isPdfHubSummaryLoading by remember(documentHandleId) { mutableStateOf(false) } - var showPdfCloudTtsSettings by remember(documentHandleId) { mutableStateOf(false) } + var isPdfTtsOverlayCollapsed by remember(documentHandleId) { mutableStateOf(false) } val annotationFile = remember(documentHandleId) { desktopPdfAnnotationFile(document.path) } val bookmarkFile = remember(documentHandleId) { desktopPdfBookmarkFile(document.path) } val richTextFile = remember(documentHandleId) { desktopPdfRichTextFile(document.path) } @@ -330,15 +409,40 @@ internal fun PdfReaderScreen( ?: 0 ) val pdfReaderFocusRequester = remember(documentHandleId) { FocusRequester() } + var pdfReaderFocusRestoreRequest by remember(documentHandleId) { mutableIntStateOf(0) } 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) + val pdfSelectionSheetActive = pdfState.selectedAnnotationId?.let { selectedId -> + pdfState.annotations.any { it.id == selectedId && it.isDesktopTextSelectionHighlight } + } == true + val shouldRestorePdfReaderFocus = + !pdfState.isSearchActive && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent && + activeTextDraft == null && + !isRichTextMode && + (textSelection == null || selectionMenuOffset == null) + val currentShouldRestorePdfReaderFocus by rememberUpdatedState(shouldRestorePdfReaderFocus) + fun requestPdfReaderFocusRestore() { + pdfReaderFocusRestoreRequest += 1 + } LaunchedEffect(isFullscreen, documentHandleId) { - repeat(if (isFullscreen) 4 else 1) { attempt -> - delay(if (attempt == 0) 80L else 120L) + for (delayMillis in DesktopPdfReaderFullscreenFocusRetryDelaysMillis) { + delay(delayMillis) + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(shouldRestorePdfReaderFocus, documentHandleId) { + if (shouldRestorePdfReaderFocus) { + delay(120L) runCatching { pdfReaderFocusRequester.requestFocus() } } } @@ -357,7 +461,17 @@ internal fun PdfReaderScreen( fun dispatchPdf(action: SharedPdfReaderAction) { val previousPage = pdfState.pageIndex + val previousAnnotationIds = pdfState.annotations.mapTo(mutableSetOf()) { it.id } val next = pdfState.reduce(action, zoomSpec) + val nextAnnotationIds = next.annotations.mapTo(mutableSetOf()) { it.id } + val removedAnnotationIds = previousAnnotationIds - nextAnnotationIds + if (removedAnnotationIds.isNotEmpty()) { + DesktopCloudSidecarSync.recordAnnotationDeletions( + documentPath = document.path, + logBookId = documentHandleId.toString(), + annotationIds = removedAnnotationIds + ) + } pdfState = next if (next.pageIndex != previousPage) { clearPdfInteractionState() @@ -503,19 +617,16 @@ internal fun PdfReaderScreen( 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 rightToLeftPdfPaginationActive = displayMode == PdfDisplayMode.PAGINATION && + pdfReaderSettings.rightToLeftPagination val isPdfTwoPageSpread = displayMode == PdfDisplayMode.PAGINATION && PdfSpreadLayout.isTwoPageSpreadEnabled(pdfReaderSettings) - val paginatedVisiblePageIndices: List = remember( + val paginatedSpreadPageIndices: List = remember( pageIndex, document.pageCount, displayMode, @@ -528,6 +639,12 @@ internal fun PdfReaderScreen( listOf(pageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))) } } + val paginatedVisiblePageIndices = remember( + paginatedSpreadPageIndices, + rightToLeftPdfPaginationActive + ) { + if (rightToLeftPdfPaginationActive) paginatedSpreadPageIndices.asReversed() else paginatedSpreadPageIndices + } val pdfPageLabel = desktopPdfPageLabel(pageIndex, document.pageCount, displayMode, pdfReaderSettings) val pdfPageScrubPreviewLabel = pageScrubPreview?.let { desktopPdfPageLabel(it, document.pageCount, displayMode, pdfReaderSettings) @@ -569,6 +686,20 @@ internal fun PdfReaderScreen( } } + LaunchedEffect(documentHandleId, displayMode) { + if (!DesktopDiagnosticsEnabled) return@LaunchedEffect + snapshotFlow { + "mode=$displayMode page=${currentPdfPageIndex + 1} scale=${currentPdfScale.formatLogFloat()} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + .distinctUntilChanged() + .collect { summary -> + logPdfZoomSettle { "scroll_state seq=$pdfZoomSettleSequence $summary" } + } + } + fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo .firstOrNull { item -> anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat() @@ -581,19 +712,61 @@ internal fun PdfReaderScreen( } } - LaunchedEffect(scale, displayMode, pageIndex) { + fun paginatedZoomPageRoot(page: Int?): Offset? { + if (page == null) return null + return paginatedPageRootOffsets[page] + ?: paginatedPageRootOffset.takeIf { page == currentPdfPageIndex } + } + + fun paginatedZoomAnchorPageIndex(anchor: Offset?): Int { + val activePageIndex = currentPdfPageIndex + if (!isPdfTwoPageSpread) return activePageIndex + val rootOffsets = paginatedPageRootOffsets.toMutableMap() + paginatedSpreadPageIndices.firstOrNull()?.let { firstSpreadPage -> + rootOffsets.putIfAbsent(firstSpreadPage, paginatedPageRootOffset) + } + val pageSizes = paginatedPageCanvasSizes.toMutableMap() + if (pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { + pageSizes.putIfAbsent(activePageIndex, pageCanvasSize) + } + return desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = pdfZoomViewportRootOffset, + anchor = anchor, + visiblePageIndices = paginatedVisiblePageIndices, + pageRootOffsets = rootOffsets, + pageSizes = pageSizes, + fallbackPageIndex = activePageIndex + ) + } + + LaunchedEffect(scale, displayMode, pageIndex, isPdfTwoPageSpread, paginatedSpreadPageIndices) { val preview = pdfZoomPreview ?: return@LaunchedEffect + val paginationPreviewPageVisible = if (isPdfTwoPageSpread) { + preview.pageIndex in paginatedSpreadPageIndices + } else { + preview.pageIndex == pageIndex + } if ( preview.displayMode != displayMode || - (preview.pageIndex != pageIndex && displayMode == PdfDisplayMode.PAGINATION) || - abs(preview.baseZoom - scale) > 0.0001f + (!paginationPreviewPageVisible && displayMode == PdfDisplayMode.PAGINATION) || + !desktopPdfZoomPreviewMatchesScale(preview, scale) ) { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=state_mismatch mode=$displayMode " + + "page=${pageIndex + 1} scale=${scale.formatLogFloat()} previewMode=${preview.displayMode} " + + "previewPage=${preview.pageIndex?.plus(1) ?: "none"} base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()}" + } pdfZoomPreview = null zoomCommitJob.getAndSet(null)?.cancel() } } fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + if (pdfZoomSettleSequence == 0) { + pdfZoomSettleSequence = 1 + } + val settleSequence = pdfZoomSettleSequence val activePageIndex = currentPdfPageIndex val activeDisplayMode = currentPdfDisplayMode logPdfZoomPerf { @@ -603,15 +776,84 @@ internal fun PdfReaderScreen( "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + "renderJobActive=${renderJob?.isActive == true}" } - pdfZoomPreview = null - val viewportRootOffsetAtZoomStart = pdfZoomViewportRootOffset - val pageRootOffsetAtZoomStart = paginatedPageRootOffset - val targetHorizontalScroll = anchor?.let { + val committedPreview = pdfZoomPreview + val viewportRootOffsetAtZoomStart = committedPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset + val committedPreviewPageIndex = committedPreview?.pageIndex ?: activePageIndex + val pageRootOffsetAtZoomStart = committedPreview?.pageRootOffset + ?: paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + val pageRootOffsetAtCommitStart = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + logPdfZoomSettle { + "commit_start seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "old=${oldZoom.formatLogFloat()} new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "preview=${committedPreview != null} previewPage=${committedPreview?.pageIndex?.plus(1) ?: "none"} " + + "previewBase=${committedPreview?.baseZoom?.formatLogFloat() ?: "none"} " + + "previewZoom=${committedPreview?.zoom?.formatLogFloat() ?: "none"} " + + "viewportStart=${viewportRootOffsetAtZoomStart.formatLogOffset()} " + + "pageStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "pageNow=${pageRootOffsetAtCommitStart.formatLogOffset()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + "${verticalListState.firstVisibleItemScrollOffset} renderPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} renderJob=${renderJob?.isActive == true}" + } + val rawTargetHorizontalScroll = anchor?.let { desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom) } - val targetVerticalScroll = anchor?.let { + val rawTargetVerticalScroll = anchor?.let { desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom) } + val paginationCommitPrediction: DesktopPdfLayoutScrollPrediction? = if (activeDisplayMode == PdfDisplayMode.PAGINATION) { + val predictedScale = zoomSpec.clamp(newZoom) + if (isPdfTwoPageSpread) { + val predictedSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + visiblePageIndex to IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = predictedSizes, + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { + desktopPdfSpreadPageGapDp(pdfReaderSettings.pdfVerticalPageGapVisible).toPx() + } + ) + } else { + document.pageSizes.getOrNull(committedPreviewPageIndex)?.let { pageSize -> + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ), + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } + } + } else { + null + } + val targetHorizontalScroll = rawTargetHorizontalScroll?.let { target -> + paginationCommitPrediction?.maxHorizontalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } + val targetVerticalScroll = rawTargetVerticalScroll?.let { target -> + paginationCommitPrediction?.maxVerticalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) { verticalZoomAnchorItem(anchor) ?.let { item -> @@ -621,38 +863,118 @@ internal fun PdfReaderScreen( oldZoom = oldZoom, newZoom = newZoom ) - val pageRootOffset = verticalPageRootOffsets[item.index] + val pageRootOffset = if (committedPreview?.pageIndex == item.index) { + committedPreview.pageRootOffset + } else { + verticalPageRootOffsets[item.index] + } Triple(item.index, fallbackOffset, pageRootOffset) } } else { null } + logPdfZoomSettle { + "commit_targets seq=$settleSequence mode=$activeDisplayMode targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"} rawH=${rawTargetHorizontalScroll ?: "none"} " + + "rawV=${rawTargetVerticalScroll ?: "none"} predictedMaxH=${paginationCommitPrediction?.maxHorizontalScroll ?: "none"} " + + "predictedMaxV=${paginationCommitPrediction?.maxVerticalScroll ?: "none"} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "targetItemOffset=${targetVerticalItem?.second ?: "none"} targetItemRoot=${targetVerticalItem?.third.formatLogOffset()}" + } + var committedPreviewForClear = committedPreview + committedPreview?.let { preview -> + val previewWithCommitTargets = preview.copy( + commitTargetHorizontalScroll = targetHorizontalScroll, + commitTargetVerticalScroll = targetVerticalScroll.takeIf { + activeDisplayMode == PdfDisplayMode.PAGINATION + } + ) + if (pdfZoomPreview == preview) { + pdfZoomPreview = previewWithCommitTargets + committedPreviewForClear = previewWithCommitTargets + logPdfZoomSettle { + "preview_commit_targets seq=$settleSequence targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"}" + } + } + } dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom)) + fun clearCommittedPreview() { + val matchesCommittedPreview = pdfZoomPreview == committedPreviewForClear + logPdfZoomSettle { + "preview_clear seq=$settleSequence match=$matchesCommittedPreview " + + "current=${pdfZoomPreview != null} committed=${committedPreview != null}" + } + if (matchesCommittedPreview) { + pdfZoomPreview = null + } + } + logPdfZoomSettle { + "zoom_dispatched seq=$settleSequence new=${newZoom.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } if (anchor != null) { - val nextAnchorJob = pdfScope.launch { - withFrameNanos { } + zoomAnchorJob.getAndSet(null)?.cancel() + val nextAnchorJob = pdfScope.launch(start = CoroutineStart.UNDISPATCHED) { when (activeDisplayMode) { PdfDisplayMode.PAGINATION -> { - suspend fun correctPageAnchor() { + if (targetHorizontalScroll != null || targetVerticalScroll != null) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } + targetVerticalScroll?.let { pageVerticalScrollState.scrollTo(it) } + logPdfZoomSettle { + "anchor_pre_scroll seq=$settleSequence mode=pagination beforeH=$beforeH beforeV=$beforeV " + + "targetH=${targetHorizontalScroll ?: "none"} targetV=${targetVerticalScroll ?: "none"} " + + "afterH=${pageHorizontalScrollState.value} afterV=${pageVerticalScrollState.value} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue}" + } + } + withFrameNanos { } + suspend fun correctPageAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + val currentRoot = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset val pageDelta = desktopPdfAnchoredPageScrollDelta( viewportRootOffset = viewportRootOffsetAtZoomStart, oldPageRootOffset = pageRootOffsetAtZoomStart, - currentPageRootOffset = paginatedPageRootOffset, + currentPageRootOffset = currentRoot, anchor = anchor, oldZoom = oldZoom, newZoom = newZoom ) - if (pageDelta != null) { - if (abs(pageDelta.x) > 1) { + val reachableDelta = pageDelta?.let { + desktopPdfReachableScrollDelta( + requestedDelta = it, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageVerticalScrollState.maxValue + ) + ) + } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=pagination beforeH=$beforeH " + + "beforeV=$beforeV delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue} " + + "rootStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "rootNow=${currentRoot.formatLogOffset()} viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } + if (reachableDelta != null) { + if (abs(reachableDelta.x) > 1) { pageHorizontalScrollState.scrollTo( - (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( + (pageHorizontalScrollState.value + reachableDelta.x).coerceAtLeast( 0 ) ) } - if (abs(pageDelta.y) > 1) { + if (abs(reachableDelta.y) > 1) { pageVerticalScrollState.scrollTo( - (pageVerticalScrollState.value + pageDelta.y).coerceAtLeast( + (pageVerticalScrollState.value + reachableDelta.y).coerceAtLeast( 0 ) ) @@ -661,14 +983,23 @@ internal fun PdfReaderScreen( pageHorizontalScrollState.scrollTo(targetHorizontalScroll) pageVerticalScrollState.scrollTo(targetVerticalScroll) } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=pagination afterH=${pageHorizontalScrollState.value} " + + "afterV=${pageVerticalScrollState.value} delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()}" + } } - correctPageAnchor() + correctPageAnchor(pass = 1) withFrameNanos { } - correctPageAnchor() + correctPageAnchor(pass = 2) } PdfDisplayMode.VERTICAL_SCROLL -> { - suspend fun correctVerticalAnchor() { + withFrameNanos { } + suspend fun correctVerticalAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeItem = verticalListState.firstVisibleItemIndex + val beforeItemOffset = verticalListState.firstVisibleItemScrollOffset val oldPageRootOffset = targetVerticalItem?.third val currentPageRootOffset = targetVerticalItem?.first?.let { verticalPageRootOffsets[it] } @@ -685,6 +1016,14 @@ internal fun PdfReaderScreen( } else { null } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=vertical beforeH=$beforeH " + + "beforeList=$beforeItem:$beforeItemOffset delta=${pageDelta.formatLogIntOffset()} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "oldRoot=${oldPageRootOffset.formatLogOffset()} " + + "currentRoot=${currentPageRootOffset.formatLogOffset()} " + + "viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } if (pageDelta != null) { if (abs(pageDelta.x) > 1) { pageHorizontalScrollState.scrollTo( @@ -702,12 +1041,29 @@ internal fun PdfReaderScreen( verticalListState.scrollToItem(itemIndex, scrollOffset) } } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=vertical afterH=${pageHorizontalScrollState.value} " + + "afterList=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "delta=${pageDelta.formatLogIntOffset()}" + } } - correctVerticalAnchor() + correctVerticalAnchor(pass = 1) withFrameNanos { } - correctVerticalAnchor() + correctVerticalAnchor(pass = 2) } } + clearCommittedPreview() + } + zoomAnchorJob.set(nextAnchorJob) + } else { + val nextAnchorJob = pdfScope.launch { + withFrameNanos { } + logPdfZoomSettle { + "anchor_skip seq=$settleSequence reason=no_anchor mode=$activeDisplayMode h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + clearCommittedPreview() } zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel() } @@ -724,27 +1080,60 @@ internal fun PdfReaderScreen( "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + "renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" } - val existingPreview = pdfZoomPreview + val previewPageIndex = when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomAnchorPageIndex(anchor) + PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex + } + val existingPreview = pdfZoomPreview?.takeIf { + it.displayMode == activeDisplayMode && + it.pageIndex == previewPageIndex && + it.baseZoom.isFinite() && + it.baseZoom > 0f && + abs(it.baseZoom - activeScale) <= 0.0001f + } + if (existingPreview == null && currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + if (existingPreview == null) { + pdfZoomSettleSequence += 1 + } + val settleSequence = pdfZoomSettleSequence 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 + val previewPageRootOffset = existingPreview?.pageRootOffset ?: when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomPageRoot(previewPageIndex) + PdfDisplayMode.VERTICAL_SCROLL -> verticalPageRootOffsets[previewPageIndex] } pdfZoomPreview = DesktopPdfZoomPreview( baseZoom = baseZoom, zoom = newZoom, anchor = anchor, displayMode = activeDisplayMode, - pageIndex = previewPageIndex + pageIndex = previewPageIndex, + viewportRootOffset = existingPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset, + pageRootOffset = previewPageRootOffset, + diagnosticSequence = settleSequence ) + logPdfZoomSettle { + "preview_update seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "previewPage=${previewPageIndex + 1} oldEvent=${oldZoom.formatLogFloat()} " + + "activeScale=${activeScale.formatLogFloat()} base=${baseZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} existing=${existingPreview != null} " + + "viewport=${pdfZoomViewportRootOffset.formatLogOffset()} pageRoot=${previewPageRootOffset.formatLogOffset()} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "list=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "renderPage=${renderedPageIndex?.plus(1) ?: "none"} renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } val nextCommitJob = pdfScope.launch { delay(DesktopPdfZoomCommitDebounceMillis) val preview = pdfZoomPreview ?: return@launch - pdfZoomPreview = null + logPdfZoomSettle { + "commit_debounce_fire seq=$settleSequence base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()} page=${preview.pageIndex?.plus(1) ?: "none"} " + + "anchor=${preview.anchor.formatLogOffset()}" + } applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor) } zoomCommitJob.getAndSet(nextCommitJob)?.cancel() @@ -758,10 +1147,45 @@ internal fun PdfReaderScreen( } fun cancelPendingPdfZoomPreview() { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=explicit pending=${pdfZoomPreview != null}" + } pdfZoomPreview = null zoomCommitJob.getAndSet(null)?.cancel() } + fun commitPendingPdfZoomPreviewForNavigation(targetPageIndex: Int) { + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = pdfZoomPreview, + currentHorizontalScroll = pageHorizontalScrollState.value, + currentVerticalScroll = pageVerticalScrollState.value + ) ?: return + val committedZoom = zoomSpec.clamp(snapshot.zoom) + logPdfZoomSettle { + "preview_navigation_commit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "target=${targetPageIndex + 1} zoom=${committedZoom.formatLogFloat()} " + + "h=${snapshot.horizontalScroll} v=${snapshot.verticalScroll}" + } + zoomCommitJob.getAndSet(null)?.cancel() + zoomAnchorJob.getAndSet(null)?.cancel() + pdfZoomPreview = null + dispatchPdf(SharedPdfReaderAction.ZoomChanged(committedZoom)) + if (displayMode == PdfDisplayMode.PAGINATION) { + pdfNavigationScrollRestoreSequence += 1 + pendingPdfNavigationScrollRestore = DesktopPdfPendingPaginatedScrollRestore( + requestId = pdfNavigationScrollRestoreSequence, + pageIndex = targetPageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)), + zoom = committedZoom, + horizontalScroll = snapshot.horizontalScroll, + verticalScroll = snapshot.verticalScroll + ) + } else { + pdfScope.launch { + pageHorizontalScrollState.scrollTo(snapshot.horizontalScroll) + } + } + } + fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) { paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale) val activePageIndex = currentPdfPageIndex @@ -775,10 +1199,17 @@ internal fun PdfReaderScreen( "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" } + logPdfZoomSettle { + "cache_put seq=$pdfZoomSettleSequence 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(documentHandleId, pageIndex, displayMode) { - runCatching { pdfReaderFocusRequester.requestFocus() } + LaunchedEffect(documentHandleId, pageIndex, displayMode, scale) { + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } } val searchQuery = pdfState.searchQuery @@ -859,8 +1290,8 @@ internal fun PdfReaderScreen( annotations.firstOrNull { it.id == selectedAnnotationId } } val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight } - val sortedAnnotations = remember(annotations) { - annotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.createdAt }) + val sortedSidebarHighlights = remember(annotations) { + desktopPdfSidebarHighlights(annotations) } val sortedEmbeddedAnnotations = remember(document.embeddedAnnotations) { document.embeddedAnnotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.index }) @@ -983,10 +1414,12 @@ internal fun PdfReaderScreen( } fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) { - val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOf(selectedColor) + fun sameRgb(left: Int, right: Int): Boolean = (left and 0x00FFFFFF) == (right and 0x00FFFFFF) + + val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOfFirst { sameRgb(it, selectedColor) } val sanitizedPalette = nextPalette.sanitized() onPdfHighlighterPaletteChange(sanitizedPalette) - if (selectedTool.isDesktopHighlighter && selectedColor !in sanitizedPalette.colors) { + if (selectedTool.isDesktopHighlighter && sanitizedPalette.colors.none { sameRgb(it, selectedColor) }) { val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot) ?: sanitizedPalette.colors.firstOrNull() colorArgb?.let { nextSelectedColor -> @@ -1005,6 +1438,10 @@ internal fun PdfReaderScreen( val pdfPopupActive = externalLinkDialogUrl != null || + showPdfAiHub || + showPdfSaveDialog || + pdfFileActionNotice != null || + isPdfFileActionLoading || selectedTextHighlight != null || selectedEmbeddedAnnotation != null || pdfExtrasState.aiResult.hasContent || @@ -1016,10 +1453,19 @@ internal fun PdfReaderScreen( } } - LaunchedEffect(aiByokSettings) { + LaunchedEffect(pdfReaderFocusRestoreRequest, documentHandleId) { + if (pdfReaderFocusRestoreRequest > 0) { + delay(140L) + if (currentShouldRestorePdfReaderFocus && !pdfPopupActive) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(aiByokSettings, cloudTtsControlsAvailable) { pdfExtrasState = pdfExtrasState.copy( cloudTts = pdfExtrasState.cloudTts.copy( - isAvailable = aiByokSettings.isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, errorMessage = null, cacheSummary = currentPdfTtsCacheSummary() ) @@ -1077,7 +1523,8 @@ internal fun PdfReaderScreen( target: Int, scrollVertical: Boolean = true, recordJump: Boolean = false, - saveRichTextBeforePageChange: Boolean = true + saveRichTextBeforePageChange: Boolean = true, + commitPendingZoomPreview: Boolean = true ) { val boundedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) val clampedTarget = if (displayMode == PdfDisplayMode.PAGINATION) { @@ -1107,6 +1554,9 @@ internal fun PdfReaderScreen( pageCount = document.pageCount ) } + if (commitPendingZoomPreview) { + commitPendingPdfZoomPreviewForNavigation(clampedTarget) + } dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget)) if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) { pdfScope.launch { @@ -1119,18 +1569,24 @@ internal fun PdfReaderScreen( if (pageScrubStartPage == null) { pageScrubStartPage = pdfState.pageIndex } - val targetPage = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.normalizePageIndex(value.roundToInt(), document.pageCount, pdfReaderSettings) - } else { - value.roundToInt().coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) - } + val targetPage = desktopPdfPageScrubTarget( + value = value, + pageCount = document.pageCount, + displayMode = displayMode, + settings = pdfReaderSettings + ) pageScrubPreview = targetPage - goToPage(targetPage) } fun finishPdfPageScrub() { val startPage = pageScrubStartPage - val targetPage = pdfState.pageIndex + val targetPage = desktopPdfPageScrubCommitTarget( + previewPage = pageScrubPreview, + currentPage = pdfState.pageIndex, + pageCount = document.pageCount + ) + pageScrubStartPage = null + pageScrubPreview = null if (startPage != null) { jumpHistory = jumpHistory.record( currentPageIndex = startPage, @@ -1138,8 +1594,7 @@ internal fun PdfReaderScreen( pageCount = document.pageCount ) } - pageScrubStartPage = null - pageScrubPreview = null + goToPage(targetPage) } fun previousPdfPageTarget(): Int { @@ -1249,24 +1704,21 @@ internal fun PdfReaderScreen( "right=${bounds.right.formatLogFloat()} bottom=${bounds.bottom.formatLogFloat()}" ) } - dispatchPdf( - SharedPdfReaderAction.AnnotationAdded( - SharedPdfAnnotation( - id = "highlight_${now}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.HIGHLIGHT, - tool = PdfInkTool.HIGHLIGHTER, - bounds = highlightBounds.firstOrNull(), - boundsList = highlightBounds, - text = selection.text, - colorArgb = SharedPdfAndroidHighlightColors.nearestArgb(colorArgb), - rangeStartIndex = selection.startIndex, - rangeEndIndex = selection.endIndex, - createdAt = now - ) - ) + val annotation = SharedPdfAnnotation( + id = "highlight_${now}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = highlightBounds.firstOrNull(), + boundsList = highlightBounds, + text = selection.text, + colorArgb = SharedPdfHighlighterPalette(listOf(colorArgb)).sanitized().colors.first(), + rangeStartIndex = selection.startIndex, + rangeEndIndex = selection.endIndex, + createdAt = now ) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + pdfState = pdfState.withDesktopPdfTextSelectionHighlightAdded(annotation, zoomSpec) + clearPdfInteractionState() } fun clearSelection() { @@ -1315,12 +1767,8 @@ internal fun PdfReaderScreen( } } - fun updatePdfAutoScroll(autoScroll: ReaderAutoScrollState) { - pdfExtrasState = pdfExtrasState.copy(autoScroll = autoScroll.sanitized()) - } - fun pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( - isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + isAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, statusMessage = statusMessage, errorMessage = errorMessage, cacheSummary = currentPdfTtsCacheSummary() @@ -1547,17 +1995,10 @@ internal fun PdfReaderScreen( } fun pdfCloudTtsUnavailableMessage(): String { - return if (aiByokSettings.serverBackedReaderAiFeatures || aiByokSettings.serverBackedCloudTts) { - pdfString( - "desktop_cloud_tts_signed_in_credits_required_desc", - "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." - ) - } else { - pdfString( - "desktop_cloud_tts_needs_gemini_key_desc", - "Add a Gemini key and select Gemini cloud TTS in AI keys and models." - ) - } + return pdfString( + "desktop_cloud_tts_signed_in_credits_required_desc", + "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." + ) } fun pdfReadScopeLabel(readScope: ReaderTtsReadScope): String { @@ -1568,7 +2009,12 @@ internal fun PdfReaderScreen( } } - fun startPdfCloudTts(readScope: ReaderTtsReadScope) { + fun startPdfCloudTts( + readScope: ReaderTtsReadScope, + startChunkIndex: Int = 0, + chunksOverride: List? = null, + restartActive: Boolean = false + ) { val settings = aiByokSettings.sanitized() logDesktopTts( "pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " + @@ -1576,10 +2022,15 @@ internal fun PdfReaderScreen( "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + "available=${ttsAdapter.isAvailable}" ) - if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) { + val ttsActive = pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused + if (ttsActive && !restartActive) { stopPdfCloudTts() return } + if (ttsActive) { + pdfTtsJob?.cancel() + pdfTtsJob = null + } if (!ttsAdapter.isAvailable) { logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable") onCloudTtsEntitlementRequired() @@ -1602,45 +2053,69 @@ internal fun PdfReaderScreen( "Preparing %1\$s", pdfReadScopeLabel(readScope) ), + progress = ReaderTtsProgress(sessionId = ttsSessionId, scope = readScope), cacheSummary = currentPdfTtsCacheSummary() ) ) + fun updatePdfTtsSession(transform: (ReaderExtrasState) -> ReaderExtrasState) { + if (pdfExtrasState.cloudTts.progress.sessionId == ttsSessionId) { + pdfExtrasState = transform(pdfExtrasState) + } + } val noTextMessage = pdfString("desktop_no_text_here_to_read", "There is no text here to read.") pdfTtsJob = pdfScope.launch { var completedChunkCount = 0 runCatching { - val ttsChunks = withContext(Dispatchers.IO) { - pdfTtsChunksForScope(readScope, pageIndex) - .filter { it.text.isNotBlank() } - .withTtsReplacements(ttsReplacementPreferences, document.path) - } + val ttsChunks = chunksOverride + ?.filter { it.text.isNotBlank() } + ?: withContext(Dispatchers.IO) { + pdfTtsChunksForScope(readScope, pageIndex) + .filter { it.text.isNotBlank() } + .withTtsReplacements(ttsReplacementPreferences, document.path) + } if (ttsChunks.isEmpty()) { logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") throw IllegalStateException(noTextMessage) } + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) val initialProgress = ReaderTtsProgress( sessionId = ttsSessionId, scope = readScope, chunks = ttsChunks, - currentChunkIndex = -1 + currentChunkIndex = boundedStartChunkIndex - 1 ) - logDesktopTts("pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") - ttsAdapter.speakChunks(document.title, readScope, ttsChunks) { index -> + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = extras.cloudTts.copy( + progress = initialProgress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + logDesktopTts( + "pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + ttsAdapter.speakChunks(document.title, readScope, playbackChunks) { relativeIndex -> if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex val chunk = ttsChunks[index] val progress = initialProgress.copy(currentChunkIndex = index) if (chunk.pageIndex != pdfState.pageIndex) { goToPage(chunk.pageIndex, recordJump = false) } - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), - progress = progress, - cacheSummary = currentPdfTtsCacheSummary() + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), + progress = progress, + cacheSummary = currentPdfTtsCacheSummary() + ) ) - ) + } logDesktopTts( "pdf_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + "page=${chunk.pageIndex + 1} offsets=${chunk.startOffset}..${chunk.endOffset} chars=${chunk.text.length}" @@ -1649,28 +2124,50 @@ internal fun PdfReaderScreen( } }.onFailure { error -> logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException && error.message != noTextMessage) error.printStackTrace() - pdfExtrasState = if (error is kotlinx.coroutines.CancellationException) { - pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) - ) - } else { - onPaidFeatureError(error.message) - pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState( - errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") + updatePdfTtsSession { extras -> + if (error is kotlinx.coroutines.CancellationException) { + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) ) - ) + } else { + onPaidFeatureError(error.message) + extras.copy( + cloudTts = pdfCloudTtsStoppedState( + errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") + ) + ) + } } }.onSuccess { logDesktopTts("pdf_sequence_success chunks=$completedChunkCount") - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) - ) + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) + ) + } } } } + fun skipPdfCloudTtsChunk(delta: Int) { + val progress = pdfExtrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startPdfCloudTts( + readScope = progress.scope, + startChunkIndex = targetIndex, + chunksOverride = progress.chunks, + restartActive = true + ) + } + + fun locatePdfCloudTtsChunk() { + val chunk = pdfExtrasState.cloudTts.progress.currentChunk ?: return + goToPage(chunk.pageIndex, recordJump = false) + } + fun togglePdfCloudTts(text: String) { val normalizedText = text.trim() val settings = aiByokSettings.sanitized() @@ -1720,59 +2217,10 @@ internal fun PdfReaderScreen( ) return } - pdfTtsJob = null - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfExtrasState.cloudTts.copy(cacheSummary = currentPdfTtsCacheSummary()) + startPdfCloudTts( + readScope = ReaderTtsReadScope.PAGE, + chunksOverride = selectionChunks ) - pdfTtsJob = pdfScope.launch { - val initialProgress = ReaderTtsProgress( - sessionId = System.currentTimeMillis(), - scope = ReaderTtsReadScope.PAGE, - chunks = selectionChunks, - currentChunkIndex = -1 - ) - runCatching { - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isLoading = true, - statusMessage = pdfString("desktop_preparing_selection", "Preparing selection"), - progress = initialProgress, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - ttsAdapter.speakChunks(document.title, ReaderTtsReadScope.PAGE, selectionChunks) { index -> - val progress = initialProgress.copy(currentChunkIndex = index) - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), - progress = progress, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - } - }.onFailure { error -> - logDesktopTts("pdf_job_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() - pdfExtrasState = pdfExtrasState.copy( - cloudTts = if (error is kotlinx.coroutines.CancellationException) { - pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) - } else { - onPaidFeatureError(error.message) - pdfCloudTtsStoppedState( - errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") - ) - } - ) - }.onSuccess { - logDesktopTts("pdf_job_success") - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) - ) - } - } } fun updateAnnotation(annotation: SharedPdfAnnotation) { @@ -1805,6 +2253,19 @@ internal fun PdfReaderScreen( annotation?.let { goToPage(it.pageIndex, recordJump = true) } } + fun dismissSelectedTextHighlightSheet() { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + requestPdfReaderFocusRestore() + } + + fun deleteSelectedTextHighlight(annotation: SharedPdfAnnotation) { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotation.id)) + requestPdfReaderFocusRestore() + } + fun goToSearchResult(targetIndex: Int) { if (searchResults.isEmpty()) return val normalizedIndex = when { @@ -1898,6 +2359,37 @@ internal fun PdfReaderScreen( } } + LaunchedEffect( + documentHandleId, + pendingPdfNavigationScrollRestore?.requestId, + pageIndex, + scale, + displayMode + ) { + val restore = pendingPdfNavigationScrollRestore ?: return@LaunchedEffect + if ( + displayMode != PdfDisplayMode.PAGINATION || + restore.pageIndex != pageIndex || + abs(restore.zoom - scale) > 0.001f + ) { + return@LaunchedEffect + } + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + logPdfZoomSettle { + "preview_navigation_restore request=${restore.requestId} page=${pageIndex + 1} " + + "zoom=${scale.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + if (pendingPdfNavigationScrollRestore == restore) { + pendingPdfNavigationScrollRestore = null + } + } + fun selectPdfPanMode() { SharedPdfRichTextLog.d( "desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}" @@ -1911,16 +2403,57 @@ internal fun PdfReaderScreen( dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) } - LaunchedEffect(pdfExtrasState.autoScroll.sanitized(), pageIndex, canGoNext, displayMode) { - val autoScroll = pdfExtrasState.autoScroll.sanitized() - if (!autoScroll.enabled) return@LaunchedEffect - if (!canGoNext) { - updatePdfAutoScroll(autoScroll.copy(enabled = false)) - return@LaunchedEffect + fun togglePdfTextSelectionMode() { + val enabled = !isTextSelectionMode + if (enabled) { + deactivateRichTextMode() + commitActiveTextDraft() } - val delayMs = (180_000f / autoScroll.speed).roundToInt().coerceIn(1_200, 12_000) - delay(delayMs.toLong()) - goToPage(nextPdfPageTarget()) + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) + if (!enabled) { + clearPdfInteractionState() + } + } + + @Composable + fun DesktopPdfBottomMarkupDock(modifier: Modifier = Modifier) { + SharedPdfInteractionDock( + isTextSelectionMode = isTextSelectionMode, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + toolConfigs = pdfState.toolConfigs, + penPalette = pdfState.penPalette, + highlighterPalette = pdfHighlighterColors, + lastActivePenTool = pdfState.lastActivePenTool, + lastActiveHighlighterTool = pdfState.lastActiveHighlighterTool, + onPanSelected = ::selectPdfPanMode, + onTextSelectionSelected = ::togglePdfTextSelectionMode, + onToolSelected = ::selectPdfAnnotationTool, + onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, + onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, + onUndo = { dispatchPdf(SharedPdfReaderAction.UndoAnnotationEdit) }, + onRedo = { dispatchPdf(SharedPdfReaderAction.RedoAnnotationEdit) }, + onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, + modifier = modifier, + allowExpandedSettings = !isPdfSearchActive && + activeTextDraft == null && + !isRichTextMode && + textSelection == null && + selectionMenuOffset == null && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent, + canUndo = pdfState.canUndoAnnotationEdit, + canRedo = pdfState.canRedoAnnotationEdit, + canClearPage = annotations.any { it.pageIndex == pageIndex }, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, + onHighlighterPaletteChange = { colors -> + onPdfHighlighterPaletteChange(SharedPdfHighlighterPalette(colors).sanitized()) + }, + onPenPaletteChange = { colors -> dispatchPdf(SharedPdfReaderAction.PenPaletteChanged(colors)) } + ) } LaunchedEffect(documentHandleId, displayMode, verticalListState) { @@ -1948,7 +2481,7 @@ internal fun PdfReaderScreen( .distinctUntilChanged() .collect { visiblePage -> if (visiblePage in 0 until document.pageCount && visiblePage != currentPdfPageIndex) { - goToPage(visiblePage, scrollVertical = false) + goToPage(visiblePage, scrollVertical = false, commitPendingZoomPreview = false) } } } @@ -1970,12 +2503,23 @@ internal fun PdfReaderScreen( "searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " + "cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" } + logPdfZoomSettle { + "render_effect seq=$pdfZoomSettleSequence page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + + "existingPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } 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}" } + logPdfZoomSettle { + "cache_hit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "scale=${cached.scale.formatLogFloat()} bitmap=${cached.render.width}x${cached.render.height}" + } renderedPage = cached.render renderedPageIndex = pageIndex renderedPageScale = cached.scale @@ -1983,9 +2527,15 @@ internal fun PdfReaderScreen( isRendering = false } } - val hasPageRender = renderedPage != null && renderedPageIndex == pageIndex + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) if (!hasPageRender) { - logPdfZoomPerf { "cache_miss page=${pageIndex + 1}; showing spinner until first render" } + logPdfZoomPerf { + "cache_miss page=${pageIndex + 1}; stale=${renderedPageIndex?.let { it + 1 } ?: "none"}" + } + logPdfZoomSettle { + "cache_miss seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "stale=${renderedPageIndex?.plus(1) ?: "none"}" + } renderedPage = null renderedPageIndex = null renderedPageScale = null @@ -2016,6 +2566,11 @@ internal fun PdfReaderScreen( "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + "hasRender=$hasPageRender opening=$isOpeningRender" } + logPdfZoomSettle { + "render_plan seq=$pdfZoomSettleSequence page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + + "hasRender=$hasPageRender opening=$isOpeningRender existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean { logPdfZoomPerf { @@ -2023,6 +2578,11 @@ internal fun PdfReaderScreen( "requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " + "hasPageRender=$hasPageRender" } + logPdfZoomSettle { + "render_scheduled seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "delayMs=$delayMillis showSpinner=$showSpinner preview=${pdfZoomPreview != null}" + } delay(delayMillis) if (showSpinner) { isRendering = true @@ -2043,6 +2603,12 @@ internal fun PdfReaderScreen( "elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " + "currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode" } + logPdfZoomSettle { + "render_stale seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} elapsedMs=$elapsedMs " + + "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + + "mode=$currentPdfDisplayMode" + } return false } result.getOrNull()?.let { render -> @@ -2062,6 +2628,13 @@ internal fun PdfReaderScreen( "requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" } + logPdfZoomSettle { + "render_end seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "elapsedMs=$elapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } renderedPage?.let { render -> logPdfSelection( "render page=${pageIndex + 1} " + @@ -2129,14 +2702,18 @@ internal fun PdfReaderScreen( val existingScale = renderedPageScale val needsFirstRender = !hasPageRender || - existingScale == null || - abs(existingScale - firstRenderScale) > DesktopPdfRenderScaleTolerance + desktopPdfRenderScaleNeedsUpgrade(existingScale, firstRenderScale) if (needsFirstRender) { renderAt( renderScale = firstRenderScale, delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L, showSpinner = !hasPageRender ) + } else { + logPdfZoomSettle { + "render_skip seq=$pdfZoomSettleSequence page=${pageIndex + 1} reason=no_scale_upgrade " + + "existingScale=${existingScale?.formatLogFloat() ?: "none"} firstScale=${firstRenderScale.formatLogFloat()}" + } } delay(DesktopPdfPaginationPrefetchDelayMillis) if (currentPdfPageIndex == pageIndex && currentPdfScale == scale && @@ -2154,19 +2731,18 @@ internal fun PdfReaderScreen( displayMode = displayMode, hasContents = document.toc.isNotEmpty(), hasBookmarks = bookmarks.isNotEmpty(), - hasAnnotations = sortedAnnotations.isNotEmpty(), + hasAnnotations = sortedSidebarHighlights.isNotEmpty(), hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), searchActive = isPdfSearchActive || searchQuery.isNotBlank(), annotationEditing = activeTextDraft != null || selectedAnnotation != null || - selectedTool != PdfInkTool.NONE || - isTextSelectionMode, + selectedTool != PdfInkTool.NONE, richTextEditing = isRichTextMode, loading = isRendering || isSearchIndexing || isPdfFileActionLoading || isReflowingThisBook, errorMessage = renderError, extrasState = pdfExtrasState, aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup ) @@ -2231,7 +2807,8 @@ internal fun PdfReaderScreen( fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { val command = event.desktopPdfKeyCommandOrNull( fullscreen = isFullscreen, - editingText = isPdfTextEditingActive() + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive ) ?: return false return runPdfKeyCommand(command) } @@ -2239,14 +2816,46 @@ internal fun PdfReaderScreen( fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { val command = event.desktopPdfKeyCommandOrNull( fullscreen = isFullscreen, - editingText = isPdfTextEditingActive() + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive ) ?: return false return runPdfKeyCommand(command) } + fun handlePdfReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (isPdfSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + return runPdfKeyCommand(DesktopPdfKeyCommand.EXIT_FULLSCREEN) + } + return false + } + return handlePdfReaderAwtKeyEvent(event) + } + + fun handlePdfReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + return when (event.keyCode) { + AwtKeyEvent.VK_F -> runPdfKeyCommand(DesktopPdfKeyCommand.SEARCH) + else -> false + } + } + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive, + allowChromeModalWindows = true, + onKeyPressed = { event -> handlePdfReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive && !isPdfSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + ) + DesktopReaderFullscreenKeyEffect( enabled = isFullscreen && !pdfPopupActive, - onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + onKeyPressed = { event -> handlePdfReaderFullscreenAwtKeyEvent(event) } ) ReaderWorkspaceShell( @@ -2265,6 +2874,16 @@ internal fun PdfReaderScreen( isBookmarked = bookmarks.any { it.pageIndex == pageIndex }, onToggleBookmark = { toggleBookmark(pageIndex) }, onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) }, + onReadAloudAction = if (cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable) { + { startPdfCloudTts(ReaderTtsReadScope.BOOK) } + } else { + null + }, + onAiHubAction = if (aiByokSettings.sanitized().areReaderAiFeaturesAvailable) { + { showPdfAiHub = true } + } else { + null + }, fileActions = pdfFileActions, onSaveCopyAction = requestSaveCopy, onPrintAction = requestPrint, @@ -2286,80 +2905,47 @@ internal fun PdfReaderScreen( .focusRequester(pdfReaderFocusRequester) .onPreviewKeyEvent(::handlePdfReaderKeyEvent) .focusable(), + closeRightPanelOnReaderTap = true, + onReaderFocusRestoreRequest = ::requestPdfReaderFocusRestore, leftSidebar = { _ -> DesktopPdfNavigationSidebar( document = document, pageIndex = pageIndex, - sortedAnnotations = sortedAnnotations, - sortedEmbeddedAnnotations = sortedEmbeddedAnnotations, + sortedHighlights = sortedSidebarHighlights, bookmarks = bookmarks, - selectedAnnotationId = selectedAnnotationId, - selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, onPageSelected = { page -> goToPage(page, recordJump = true) }, onAnnotationOpened = ::goToAnnotation, onAnnotationSelected = ::selectAnnotation, - onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) }, - onEmbeddedAnnotationOpened = ::goToEmbeddedAnnotation, - onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation + onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) } ) }, rightInspector = { DesktopPdfInspectorPanel( document = document, - pageIndex = pageIndex, displayMode = displayMode, pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, customTextureIds = customTextureIds, onImportTexture = onImportTexture, onReaderSettingsChange = ::updatePdfReaderSettings, - zoomControlScale = zoomControlScale, - zoomSpec = zoomSpec, - isTextSelectionMode = isTextSelectionMode, selectedTool = selectedTool, isRichTextMode = isRichTextMode, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - pdfHighlighterColors = pdfHighlighterColors, pdfHighlighterPalette = pdfHighlighterPalette, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, effectiveTextStyleConfig = effectiveTextStyleConfig, richTextController = richTextController, pdfExtrasState = pdfExtrasState, aiByokSettings = aiByokSettings, - externalLookupAvailable = featurePolicy.externalLookup, - cloudTtsFeatureAvailable = featurePolicy.aiAndCloud, + cloudTtsFeatureAvailable = cloudTtsControlsAvailable, ttsReplacementPreferences = ttsReplacementPreferences, - pageText = { currentPdfPageText() }, onDisplayModeSelected = { mode -> commitActiveTextDraft() + updatePdfReaderSettings( + pdfReaderSettings.copy(readingMode = mode.toDesktopReaderReadingMode()) + ) dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(mode)) }, - onPageScrub = ::updatePdfPageScrub, - onPageScrubFinished = ::finishPdfPageScrub, - onZoomOut = { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) - }, - onZoomIn = { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) - }, - onZoomChange = { zoom -> - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomChanged(zoom)) - }, - onSelectPanMode = ::selectPdfPanMode, - onTextSelectionModeToggle = { - val enabled = !isTextSelectionMode - if (enabled) { - deactivateRichTextMode() - commitActiveTextDraft() - } - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) - if (!enabled) { - clearPdfInteractionState() - } - }, onRichTextModeToggle = { if (isRichTextMode) { deactivateRichTextMode() @@ -2367,21 +2953,12 @@ internal fun PdfReaderScreen( activateRichTextMode() } }, - onToolSelected = ::selectPdfAnnotationTool, - onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, - onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, - onUndoPage = { dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex)) }, - onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, - onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onTextStyleChange = ::updateTextStyleConfig, - onExternalLookup = ::openPdfExternalLookup, - onOpenAiHub = { showPdfAiHub = true }, - onCloudTtsStart = ::startPdfCloudTts, - onCloudTtsPauseResume = ::pauseResumePdfCloudTts, - onCloudTtsStop = ::stopPdfCloudTts, onCloudTtsClearCache = ::clearPdfCloudTtsCache, - onAutoScrollChange = ::updatePdfAutoScroll, + onCloudTtsVoiceChange = { voiceId -> + onAiByokSettingsChange(aiByokSettings.sanitized().copy(ttsSpeakerId = voiceId)) + }, onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange ) }, @@ -2404,38 +2981,30 @@ internal fun PdfReaderScreen( onJumpForward = ::goForwardInJumpHistory, onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, extraContent = { - if (featurePolicy.aiAndCloud) { - val ttsActive = pdfExtrasState.cloudTts.isLoading || - pdfExtrasState.cloudTts.isPlaying || - pdfExtrasState.cloudTts.isPaused - if (showPdfCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = aiByokSettings, - isTtsActive = ttsActive, - showCredits = showPaidCredits, + cloudTts = pdfExtrasState.cloudTts, credits = credits, - cacheSummary = pdfExtrasState.cloudTts.cacheSummary, - onClearCache = ::clearPdfCloudTtsCache, - onSettingsChange = { next -> - onAiByokSettingsChange( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - onRead = { startPdfCloudTts(ReaderTtsReadScope.BOOK) }, - onPauseResume = ::pauseResumePdfCloudTts, - onStop = ::stopPdfCloudTts, - onOpenSettings = { showPdfCloudTtsSettings = !showPdfCloudTtsSettings } - ) } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) } ) }, @@ -2457,38 +3026,30 @@ internal fun PdfReaderScreen( onJumpForward = ::goForwardInJumpHistory, onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, extraContent = { - if (featurePolicy.aiAndCloud) { - val ttsActive = pdfExtrasState.cloudTts.isLoading || - pdfExtrasState.cloudTts.isPlaying || - pdfExtrasState.cloudTts.isPaused - if (showPdfCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = aiByokSettings, - isTtsActive = ttsActive, - showCredits = showPaidCredits, + cloudTts = pdfExtrasState.cloudTts, credits = credits, - cacheSummary = pdfExtrasState.cloudTts.cacheSummary, - onClearCache = ::clearPdfCloudTtsCache, - onSettingsChange = { next -> - onAiByokSettingsChange( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - onRead = { startPdfCloudTts(ReaderTtsReadScope.BOOK) }, - onPauseResume = ::pauseResumePdfCloudTts, - onStop = ::stopPdfCloudTts, - onOpenSettings = { showPdfCloudTtsSettings = !showPdfCloudTtsSettings } - ) } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) } ) } @@ -2520,22 +3081,32 @@ internal fun PdfReaderScreen( onNext = { goToSearchResult(activeSearchIndex + 1) }, onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) } ) + val pdfViewportBackground = desktopPdfViewportBackgroundColor( + displayMode = displayMode, + pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, + appBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, + isVerticalPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) 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(if (isFullscreen) 0.dp else 4.dp)) - .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + .fillMaxSize() + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=vertical " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2553,6 +3124,9 @@ internal fun PdfReaderScreen( horizontalAlignment = Alignment.CenterHorizontally ) { items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> + val verticalZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + } DesktopVerticalPdfPage( document = document, pageIndex = verticalPageIndex, @@ -2576,12 +3150,20 @@ internal fun PdfReaderScreen( richTextController = richTextController, isRichTextMode = isRichTextMode, readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup, themeStyle = pdfThemeStyle, shouldRender = verticalPageIndex in verticalRenderWindow, - zoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + zoomPreview = verticalZoomPreview, + zoomPreviewAnchorPageRootOffset = verticalZoomPreview + ?.pageIndex + ?.let { verticalPageRootOffsets[it] }, + zoomPreviewScrollBounds = verticalZoomPreview?.let { + desktopPdfZoomScrollBoundsWithCommitTargets( + preview = it, + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue + ) }, zoomViewportRootOffset = pdfZoomViewportRootOffset, showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, @@ -2615,6 +3197,16 @@ internal fun PdfReaderScreen( } }, onPagePositioned = { page, offset -> + val previousOffset = verticalPageRootOffsets[page] + if (previousOffset != offset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=vertical page=${page + 1} " + + "prevRoot=${previousOffset.formatLogOffset()} nextRoot=${offset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${verticalZoomPreview != null} " + + "h=${pageHorizontalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + } verticalPageRootOffsets[page] = offset } ) @@ -2624,7 +3216,7 @@ internal fun PdfReaderScreen( listState = verticalListState, pageCount = document.pageCount, currentPage = pageIndex, - isDarkMode = verticalViewportBackground.luminance() < 0.5f, + isDarkMode = pdfViewportBackground.luminance() < 0.5f, modifier = Modifier.align(Alignment.CenterEnd) ) DesktopPdfPageScrubOverlay( @@ -2638,9 +3230,18 @@ internal fun PdfReaderScreen( Box( modifier = Modifier .fillMaxSize() - .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=spread " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2652,10 +3253,50 @@ internal fun PdfReaderScreen( .padding(24.dp), contentAlignment = Alignment.TopCenter ) { + val spreadPageGap = desktopPdfSpreadPageGapDp( + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) Row( - horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(spreadPageGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.Top ) { + val spreadZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.PAGINATION + } + val spreadPredictedPageCanvasSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + val pageDisplayScale = zoomSpec.clamp(scale) + visiblePageIndex to IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + val spreadLayoutPrediction = spreadZoomPreview?.let { + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = spreadPredictedPageCanvasSizes, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { spreadPageGap.toPx() } + ) + } + val spreadZoomAnchorPageRootOffset = spreadZoomPreview + ?.pageIndex + ?.let { spreadLayoutPrediction?.pageRootOffsets?.get(it) ?: paginatedZoomPageRoot(it) } + val spreadZoomScrollBounds = spreadZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = spreadLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = spreadLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } paginatedVisiblePageIndices.forEach { spreadPageIndex -> DesktopVerticalPdfPage( document = document, @@ -2680,13 +3321,13 @@ internal fun PdfReaderScreen( richTextController = richTextController, isRichTextMode = isRichTextMode, readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, externalLookupAvailable = featurePolicy.externalLookup, themeStyle = pdfThemeStyle, shouldRender = true, - zoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.PAGINATION - }, + zoomPreview = spreadZoomPreview, + zoomPreviewAnchorPageRootOffset = spreadZoomAnchorPageRootOffset, + zoomPreviewScrollBounds = spreadZoomScrollBounds, zoomViewportRootOffset = pdfZoomViewportRootOffset, showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, onSelectPage = { @@ -2719,8 +3360,21 @@ internal fun PdfReaderScreen( pageVerticalScrollState.scrollBy(-delta.y) } }, + onPageSizeChanged = { page, size -> + paginatedPageCanvasSizes[page] = size + }, onPagePositioned = { page, offset -> - if (page == paginatedVisiblePageIndices.firstOrNull()) { + paginatedPageRootOffsets[page] = offset + if (page == paginatedSpreadPageIndices.firstOrNull()) { + if (offset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=spread page=${page + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${offset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${spreadZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + } paginatedPageRootOffset = offset } } @@ -2737,9 +3391,18 @@ internal fun PdfReaderScreen( Box( modifier = Modifier .fillMaxSize() - .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } .onGloballyPositioned { coordinates -> - pdfZoomViewportRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=pagination " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset } .desktopPdfZoomGestures( currentZoom = scale, @@ -2751,17 +3414,48 @@ internal fun PdfReaderScreen( .padding(24.dp), contentAlignment = Alignment.TopCenter ) { - val currentPageRender = renderedPage.takeIf { renderedPageIndex == pageIndex } + val paginatedPageDisplay = renderedPageIndex + ?.takeIf { displayPageIndex -> + renderedPage != null && desktopPdfRenderBelongsToPage(displayPageIndex, pageIndex) + } + ?.let { displayPageIndex -> + renderedPage?.let { render -> + DesktopPdfPaginatedPageDisplay( + pageIndex = displayPageIndex, + render = render + ) + } + } when { - currentPageRender != null -> { + renderError != null && paginatedPageDisplay?.pageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + paginatedPageDisplay != null -> { + Crossfade( + targetState = paginatedPageDisplay.pageIndex, + animationSpec = tween(DesktopPdfPaginationPageTurnAnimationMillis), + label = "DesktopPdfPaginatedPage" + ) { displayPageIndex -> + val displayPageIsCurrent = displayPageIndex == currentPdfPageIndex + val pageIndex = displayPageIndex + val currentPageRender = if (displayPageIndex == paginatedPageDisplay.pageIndex) { + paginatedPageDisplay.render + } else { + paginatedRenderCache[displayPageIndex]?.render ?: paginatedPageDisplay.render + } val pageSize = document.pageSizes.getOrNull(pageIndex) if (pageSize == null) { Text(readerString("desktop_failed_render_page", "Failed to render page."), color = MaterialTheme.colorScheme.error) - return@Box + return@Crossfade } val pageDisplayScale = zoomSpec.clamp(scale) val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() } val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() } + val predictedPageCanvasSize = IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) val pageRenderScale = currentPageRender.width / pageSize.width val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { annotations @@ -2843,14 +3537,41 @@ internal fun PdfReaderScreen( it.displayMode == PdfDisplayMode.PAGINATION && it.pageIndex == pageIndex } + val pageLayoutPrediction = pageZoomPreview?.let { + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = predictedPageCanvasSize, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } Box( modifier = Modifier .size(pageWidthDp, pageHeightDp) .onGloballyPositioned { coordinates -> - paginatedPageRootOffset = coordinates.positionInRoot() + val rootOffset = coordinates.positionInRoot() + if (rootOffset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${pageZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} canvas=${pageCanvasSize.formatLogSize()}" + } + } + paginatedPageRootOffset = rootOffset + paginatedPageRootOffsets[pageIndex] = rootOffset } .onSizeChanged { size -> if (pageCanvasSize != size) { + logPdfZoomSettle { + "page_size seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prev=${pageCanvasSize.formatLogSize()} next=${size.formatLogSize()} " + + "scale=${scale.formatLogFloat()} preview=${pageZoomPreview != null} " + + "bitmap=${currentPageRender.width}x${currentPageRender.height}" + } logPdfSelection( "layout page=${pageIndex + 1} " + "canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + @@ -2859,22 +3580,51 @@ internal fun PdfReaderScreen( ) } pageCanvasSize = size + paginatedPageCanvasSizes[pageIndex] = size } .desktopPdfZoomPreviewLayer( preview = pageZoomPreview, currentZoom = scale, viewportRootOffset = pdfZoomViewportRootOffset, pageRootOffset = paginatedPageRootOffset, - pageCanvasSize = pageCanvasSize + pageCanvasSize = pageCanvasSize, + commitPageRootOffset = pageLayoutPrediction?.rootOffset, + scrollBounds = pageZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } ) .background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { - if (isRichTextMode) return@pointerInput + .pointerInput( + pageIndex, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val point = event.changes.firstOrNull()?.position ?: continue if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=paginated_inline_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { currentPdfAnnotations.asReversed().firstOrNull { it.isDesktopTextSelectionHighlight && @@ -2885,6 +3635,10 @@ internal fun PdfReaderScreen( null } if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } selectAnnotation(highlightHit) clearPdfInteractionState() event.changes.forEach { it.consume() } @@ -2893,6 +3647,10 @@ internal fun PdfReaderScreen( if (selectedTool != PdfInkTool.TEXT) { val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } logPdfLink( "tap_hit mode=page page=${pageIndex + 1} " + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + @@ -2907,6 +3665,10 @@ internal fun PdfReaderScreen( it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) } if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } selectEmbeddedAnnotation(embeddedHit) clearPdfInteractionState() event.changes.forEach { it.consume() } @@ -2914,10 +3676,19 @@ internal fun PdfReaderScreen( currentTextSelection != null && selectionMenuOffset == null ) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } selectionMenuOffset = null textSelection = null selectionStartHit = null selectionEndHit = null + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=none consumed=false" + } } } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { val selection = currentTextSelection @@ -2935,32 +3706,50 @@ internal 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, + pageCanvasSize, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "paginated_inline_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=paginated_inline_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" } - ) + 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 + .pointerInput( + pageIndex, + selectedTool, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture @@ -3003,10 +3792,11 @@ internal fun PdfReaderScreen( textStyleConfig, activeTextDraft?.id, isRichTextMode, + displayPageIsCurrent, pageCanvasSize, currentPageRender.width, currentPageRender.height ) { - if (isRichTextMode) return@pointerInput + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput if (isTextSelectionMode) { var latestSelectionDragPoint: Offset? = null var lastSelectionPreviewAt = 0L @@ -3344,6 +4134,10 @@ internal fun PdfReaderScreen( .matchParentSize() .pointerInput(pageIndex, selectionMenuOffset) { detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=paginated_inline_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } selectionMenuOffset = null textSelection = null selectionStartHit = null @@ -3381,11 +4175,12 @@ internal fun PdfReaderScreen( clearSelection() }, showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable, + showSpeak = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, showSearch = featurePolicy.externalLookup, onClear = ::clearSelection ) } + } } isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) renderError != null -> Text( @@ -3442,23 +4237,23 @@ internal fun PdfReaderScreen( selectedTextHighlight != null -> { DesktopReaderBottomSheet( title = selectedTextHighlight.desktopSheetTitle(), - onDismiss = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } + onDismiss = ::dismissSelectedTextHighlightSheet ) { DesktopPdfAnnotationEditor( annotation = selectedTextHighlight, onUpdate = ::updateAnnotation, - onDelete = { deleteAnnotation(selectedTextHighlight.id) }, - onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) }, + onDelete = { deleteSelectedTextHighlight(selectedTextHighlight) }, + onClose = ::dismissSelectedTextHighlightSheet, onCopy = { clipboardManager.setText(AnnotatedString(selectedTextHighlight.text)) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + dismissSelectedTextHighlightSheet() }, showSearch = featurePolicy.externalLookup, highlighterPalette = pdfHighlighterColors, onHighlighterPaletteChange = ::updatePdfHighlighterPalette, onSearch = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text) - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + dismissSelectedTextHighlightSheet() } ) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbing.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbing.kt new file mode 100644 index 0000000..c1aea6b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbing.kt @@ -0,0 +1,28 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.pdf.PdfSpreadLayout +import com.aryan.reader.shared.reader.ReaderSettings +import kotlin.math.roundToInt + +internal fun desktopPdfPageScrubTarget( + value: Float, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings) + } else { + clampedPage + } +} + +internal fun desktopPdfPageScrubCommitTarget( + previewPage: Int?, + currentPage: Int, + pageCount: Int +): Int { + return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0)) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt index 79ebe80..8f8a1df 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.aryan.reader.shared.pdf.PdfPageBounds -import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog import com.aryan.reader.shared.ui.SharedSelectionMenuRect @@ -292,11 +291,15 @@ internal fun PdfSelectionMenu( val anchor = menuOffset ?: return val selectionBounds = selection.canvasBounds(canvasSize) val paletteColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors } + val density = LocalDensity.current var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { mutableStateOf(null) } + var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf>(emptyList()) + } val actions = buildList { add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy)) if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) @@ -304,13 +307,38 @@ internal fun PdfSelectionMenu( if (showSearch) add(PdfSelectionMenuAction(readerString("action_search", "Search"), DesktopPdfSelectionMenuIcons.Search, onSearch)) add(PdfSelectionMenuAction(readerString("action_clear", "Clear"), Icons.Default.Close, onClear, isDestructive = true)) } - val estimatedHeight = PdfSelectionMenuPaletteHeightPx + - (((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx) + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { paletteColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = paletteColors + } + editingHighlighterSlot = slotIndex + } + + val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1) + val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() } + val estimatedHeightPx = with(density) { + PdfSelectionMenuPaletteHeight.toPx() + + (actionRowCount * PdfSelectionMenuActionRowHeight.toPx()) + } val placement = sharedSelectionMenuPlacement( viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height), popup = SharedSelectionMenuSize( - width = PdfSelectionMenuWidthPx.roundToInt(), - height = estimatedHeight.roundToInt() + width = popupWidthPx.roundToInt(), + height = estimatedHeightPx.roundToInt() ), selection = if (selectionBounds != null) { SharedSelectionMenuRect( @@ -327,8 +355,8 @@ internal fun PdfSelectionMenu( bottom = anchor.y ) }, - marginPx = PdfSelectionMenuMarginPx, - gapPx = PdfSelectionMenuAnchorGapPx + marginPx = with(density) { PdfSelectionMenuMargin.toPx() }, + gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() } ) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Surface( @@ -385,7 +413,7 @@ internal fun PdfSelectionMenu( ) ) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) - .clickable { editingHighlighterSlot = 0 } + .clickable { openHighlighterEditor(0) } ) } HorizontalDivider() @@ -436,20 +464,27 @@ internal fun PdfSelectionMenu( } } editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex) - val initialColor = Color(paletteColors[slot]).copy(alpha = 1f) + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } SharedHsvColorPickerDialog( initialColor = initialColor, title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), onDismiss = { editingHighlighterSlot = null }, onSave = { color -> + val nextColors = updateHighlighterDraft(slot, color) onHighlighterPaletteChange( - SharedPdfHighlighterPalette(paletteColors).withColorAt( - slotIndex = slot, - colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - ) + SharedPdfHighlighterPalette(nextColors).sanitized() ) editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) } ) { liveColor -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -458,7 +493,7 @@ internal fun PdfSelectionMenu( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { - paletteColors.forEachIndexed { index, argb -> + highlighterDraftColors().forEachIndexed { index, argb -> val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) Box( modifier = Modifier @@ -467,10 +502,14 @@ internal fun PdfSelectionMenu( .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), + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, shape = RoundedCornerShape(21.dp) ) - .clickable { editingHighlighterSlot = index }, + .clickable { openHighlighterEditor(index) }, contentAlignment = Alignment.Center ) { Text( @@ -494,11 +533,11 @@ private data class PdfSelectionMenuAction( val isDestructive: Boolean = false ) -private const val PdfSelectionMenuWidthPx = 220f -private const val PdfSelectionMenuPaletteHeightPx = 54f -private const val PdfSelectionMenuActionRowHeightPx = 66f -private const val PdfSelectionMenuAnchorGapPx = 16f -private const val PdfSelectionMenuMarginPx = 6f +private val PdfSelectionMenuWidth = 220.dp +private val PdfSelectionMenuPaletteHeight = 54.dp +private val PdfSelectionMenuActionRowHeight = 66.dp +private val PdfSelectionMenuAnchorGap = 16.dp +private val PdfSelectionMenuMargin = 6.dp private const val DesktopPdfSelectionHandleTouchWidthPx = 44f private const val DesktopPdfSelectionHandleTouchTopPx = 8f private const val DesktopPdfSelectionHandleTouchBottomPx = 40f diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt index 0f23096..b6f6543 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarEffects.kt @@ -36,18 +36,40 @@ internal fun DesktopPdfAnnotationSidecarEffect( emptyList() } onAnnotationsLoaded(loadedAnnotations) + logDesktopCloudAnnotations { + "desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " + + "exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}" + } onAnnotationsLoadedChange(true) } LaunchedEffect(documentHandleId, annotations, annotationsLoaded) { if (!annotationsLoaded) return@LaunchedEffect - withContext(Dispatchers.IO) { + val changed = withContext(Dispatchers.IO) { runCatching { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - } + val nextJson = SharedPdfAnnotationSerializer.encode(annotations) + when { + annotations.isEmpty() && annotationFile.isFile -> { + annotationFile.delete() + } + annotations.isEmpty() -> false + annotationFile.isFile && annotationFile.readText() == nextJson -> false + else -> { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + logDesktopCloudAnnotations { + "desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " + + "path=${annotationFile.absolutePath.logPreview(140)}" + } + onLocalSidecarsChanged() } - onLocalSidecarsChanged() } } @@ -76,13 +98,23 @@ internal fun DesktopPdfBookmarkSidecarEffect( LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) { if (!bookmarksLoaded) return@LaunchedEffect - withContext(Dispatchers.IO) { + val changed = withContext(Dispatchers.IO) { runCatching { - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) - } + val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks) + when { + bookmarks.isEmpty() && !bookmarkFile.isFile -> false + bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false + else -> { + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + onLocalSidecarsChanged() } - onLocalSidecarsChanged() } } @@ -177,3 +209,7 @@ internal fun DesktopPdfSearchResultsEffect( onSearchResultsChange(results) } } + +private fun File.lastModifiedIfFileForCloudLog(): Long { + return if (isFile) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt index 9cdc61c..3dda716 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt @@ -1,27 +1,49 @@ package com.aryan.reader.desktop import java.io.File +import java.security.MessageDigest import java.util.Base64 -private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1" +private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2" internal fun desktopPdfAnnotationFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json") + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json") + ) +} + +internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json") + ) } internal fun desktopPdfBookmarkFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json") + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json") + ) } internal fun desktopPdfRichTextFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json") + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json") + ) } internal fun desktopPdfSearchIndexFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") + val safeName = desktopPdfDocumentKey(documentPath) return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") } @@ -38,7 +60,7 @@ internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFil if (parts.size == 2) parts[0] to parts[1] else null } .toMap() - val isFresh = metadata["pathHash"] == document.path.hashCode().toString() && + val isFresh = metadata["pathKey"] == desktopPdfDocumentKey(document.path) && metadata["fileSize"] == sourceFile.length().toString() && metadata["lastModified"] == sourceFile.lastModified().toString() && metadata["pageCount"] == document.pageCount.toString() @@ -65,7 +87,7 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: val encoder = Base64.getEncoder() val payload = buildString { appendLine(DesktopPdfSearchIndexHeader) - appendLine("pathHash\t${document.path.hashCode()}") + appendLine("pathKey\t${desktopPdfDocumentKey(document.path)}") appendLine("fileSize\t${sourceFile.length()}") appendLine("lastModified\t${sourceFile.lastModified()}") appendLine("pageCount\t${document.pageCount}") @@ -81,3 +103,30 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: indexFile.writeText(payload, Charsets.UTF_8) } } + +internal fun desktopPdfDocumentKey(documentPath: String): String { + val normalizedPath = runCatching { File(documentPath).canonicalPath } + .getOrElse { documentPath.trim() } + return sha256Hex(normalizedPath).take(32) +} + +private fun desktopPdfLegacyDocumentKey(documentPath: String): String { + return documentPath.hashCode().toString().replace("-", "n") +} + +private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File { + if (!file.exists() && legacyFile.isFile && legacyFile != file) { + runCatching { + file.parentFile?.mkdirs() + if (!legacyFile.renameTo(file)) { + legacyFile.copyTo(file, overwrite = false) + } + } + } + return file +} + +private fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSyncSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSyncSidecars.kt new file mode 100644 index 0000000..cd06a93 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSyncSidecars.kt @@ -0,0 +1,156 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec +import com.aryan.reader.shared.pdf.SharedPdfBookmark +import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer +import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +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.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private val desktopPdfSyncJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? { + val annotations = SharedPdfAnnotationSerializer.decode(rawJson) + if (annotations.isEmpty()) return null + return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) +} + +internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? { + val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return null + val document = SharedPdfRichTextSerializer.decodeElement(element) + if (document.text.isEmpty() && document.spans.isEmpty()) return null + return SharedPdfRichTextSerializer.encodeElement(document) +} + +internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? { + if (book.type != FileType.PDF) return null + val path = book.path?.takeIf { it.isNotBlank() } ?: return null + val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null + return desktopPdfBookmarksMetadataJson( + bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()), + lastPageIndex = book.lastPageIndex + ) +} + +internal fun desktopPdfBookmarksMetadataJson( + bookmarks: List, + lastPageIndex: Int? +): String { + val totalPages = maxOf( + (lastPageIndex ?: 0) + 1, + (bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1 + ).coerceAtLeast(1) + return desktopPdfSyncJson.encodeToString( + JsonElement.serializer(), + JsonArray( + bookmarks.map { bookmark -> + JsonObject( + mapOf( + "pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)), + "title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }), + "totalPages" to JsonPrimitive(totalPages) + ) + ) + } + ) + ) +} + +internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long { + if (book.type != FileType.PDF) return 0L + val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L + return desktopPdfBookmarkFile(path).lastModifiedIfFile() +} + +internal fun importDesktopPdfBookmarksMetadata( + book: BookItem, + bookmarksJson: String?, + timestamp: Long +): Boolean { + if (book.type != FileType.PDF) return false + val path = book.path?.takeIf { it.isNotBlank() } ?: return false + val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false + val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson) + val bookmarkFile = desktopPdfBookmarkFile(path) + val localTimestamp = bookmarkFile.lastModifiedIfFile() + if (timestamp <= localTimestamp + 1000L) return false + + if (bookmarks.isEmpty()) { + if (bookmarkFile.isFile) bookmarkFile.delete() + return true + } + + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) + bookmarkFile.setLastModified(timestamp) + return true +} + +internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List { + val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return emptyList() + + root.jsonArrayOrNull()?.let { androidBookmarks -> + return androidBookmarks.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null + SharedPdfBookmark( + pageIndex = pageIndex.coerceAtLeast(0), + label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}", + createdAt = obj.longString("createdAt") ?: 0L + ) + } + } + + return SharedPdfBookmarkSerializer.decode(rawJson) +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile()) lastModified() else 0L +} + +private fun JsonElement.jsonArrayOrNull(): JsonArray? { + if (this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.string(name: String): String? { + return this[name] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } +} + +private fun JsonObject.int(name: String): Int? { + return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull +} + +private fun JsonObject.longString(name: String): Long? { + return string(name)?.toLongOrNull() + ?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt index f79df97..9d42bb6 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfTheme.kt @@ -2,12 +2,15 @@ package com.aryan.reader.desktop import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.aryan.reader.shared.PdfDisplayMode import com.aryan.reader.shared.ReaderTheme +import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp -internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL +internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION internal val DesktopDefaultPdfVerticalPageGap = 8.dp +internal val DesktopDefaultPdfSpreadPageGap = 18.dp internal fun desktopPdfPageBackgroundColor( theme: ReaderTheme, @@ -28,3 +31,26 @@ internal fun desktopPdfVerticalViewportBackgroundColor( ): Color { return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor } + +internal fun desktopPdfViewportBackgroundColor( + displayMode: PdfDisplayMode, + pageBackgroundColor: Color, + appBackgroundColor: Color, + isVerticalPageGapVisible: Boolean +): Color { + return when (displayMode) { + PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackgroundColor, + gapBackgroundColor = appBackgroundColor, + isPageGapVisible = isVerticalPageGapVisible + ) + PdfDisplayMode.PAGINATION -> appBackgroundColor + } +} + +internal fun desktopPdfSpreadPageGapDp( + isPageGapVisible: Boolean +): Dp = pdfVerticalPageGapDp( + isPageGapVisible = isPageGapVisible, + defaultGap = DesktopDefaultPdfSpreadPageGap +) diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt index 3296c39..d7fd191 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt @@ -22,6 +22,7 @@ import kotlin.math.exp import kotlin.math.roundToInt private const val DesktopPdfZoomGestureFrameMillis = 16L +private const val DesktopPdfZoomPreviewTolerance = 0.0001f internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { @@ -117,19 +118,320 @@ internal fun desktopPdfPaginationFirstRenderScale( return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) } +internal fun desktopPdfRenderBelongsToPage( + renderedPageIndex: Int?, + requestedPageIndex: Int +): Boolean { + return renderedPageIndex == requestedPageIndex +} + +internal fun desktopPdfRenderScaleNeedsUpgrade( + renderedScale: Float?, + requestedScale: Float +): Boolean { + if (renderedScale == null) return true + if (!requestedScale.isFinite() || requestedScale <= 0f) return false + if (!renderedScale.isFinite() || renderedScale <= 0f) return true + return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance +} + +internal fun desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset: Offset, + anchor: Offset?, + visiblePageIndices: List, + pageRootOffsets: Map, + pageSizes: Map, + fallbackPageIndex: Int +): Int { + if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex + val candidates = visiblePageIndices.mapNotNull { pageIndex -> + pageRootOffsets[pageIndex]?.let { root -> + pageIndex to root + } + } + if (candidates.isEmpty()) return fallbackPageIndex + candidates.firstOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] ?: return@firstOrNull false + val width = size.width.toFloat() + val height = size.height.toFloat() + rootAnchor.x >= root.x && + rootAnchor.x <= root.x + width && + rootAnchor.y >= root.y && + rootAnchor.y <= root.y + height + }?.let { return it.first } + return candidates.minByOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] + val dx: Float + val dy: Float + if (size == null) { + dx = rootAnchor.x - root.x + dy = rootAnchor.y - root.y + } else { + val right = root.x + size.width.toFloat() + val bottom = root.y + size.height.toFloat() + dx = when { + rootAnchor.x < root.x -> root.x - rootAnchor.x + rootAnchor.x > right -> rootAnchor.x - right + else -> 0f + } + dy = when { + rootAnchor.y < root.y -> root.y - rootAnchor.y + rootAnchor.y > bottom -> rootAnchor.y - bottom + else -> 0f + } + } + dx * dx + dy * dy + }?.first ?: fallbackPageIndex +} + internal data class DesktopPdfZoomPreview( val baseZoom: Float, val zoom: Float, val anchor: Offset?, val displayMode: PdfDisplayMode, - val pageIndex: Int? + val pageIndex: Int?, + val viewportRootOffset: Offset = Offset.Zero, + val pageRootOffset: Offset? = null, + val commitTargetHorizontalScroll: Int? = null, + val commitTargetVerticalScroll: Int? = null, + val diagnosticSequence: Int = 0 ) +internal data class DesktopPdfZoomScrollBounds( + val currentHorizontalScroll: Int? = null, + val maxHorizontalScroll: Int? = null, + val currentVerticalScroll: Int? = null, + val maxVerticalScroll: Int? = null +) + +internal interface DesktopPdfLayoutScrollPrediction { + val maxHorizontalScroll: Int + val maxVerticalScroll: Int +} + +internal data class DesktopPdfSinglePageLayoutPrediction( + val rootOffset: Offset, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + +internal data class DesktopPdfSpreadLayoutPrediction( + val pageRootOffsets: Map, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + internal data class DesktopPdfCachedPageRender( val render: DesktopPdfPageRender, val scale: Float ) +internal data class DesktopPdfNavigationZoomSnapshot( + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfNavigationZoomSnapshot( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + currentVerticalScroll: Int +): DesktopPdfNavigationZoomSnapshot? { + val activePreview = preview ?: return null + val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null + val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null + val anchor = activePreview.anchor + return DesktopPdfNavigationZoomSnapshot( + zoom = targetZoom, + horizontalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom) + } ?: currentHorizontalScroll.coerceAtLeast(0), + verticalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom) + } ?: currentVerticalScroll.coerceAtLeast(0) + ) +} + +internal fun desktopPdfZoomPreviewMatchesScale( + preview: DesktopPdfZoomPreview, + scale: Float +): Boolean { + return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance || + abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance +} + +internal fun desktopPdfReachableScrollDelta( + currentScroll: Int?, + maxScroll: Int?, + requestedDelta: Int +): Int { + if (currentScroll == null || maxScroll == null) return requestedDelta + val safeMax = maxScroll.coerceAtLeast(0) + val safeCurrent = currentScroll.coerceIn(0, safeMax) + val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax) + return targetScroll - safeCurrent +} + +internal fun desktopPdfReachableScrollDelta( + requestedDelta: IntOffset, + scrollBounds: DesktopPdfZoomScrollBounds? +): IntOffset { + if (scrollBounds == null) return requestedDelta + return IntOffset( + x = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentHorizontalScroll, + maxScroll = scrollBounds.maxHorizontalScroll, + requestedDelta = requestedDelta.x + ), + y = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentVerticalScroll, + maxScroll = scrollBounds.maxVerticalScroll, + requestedDelta = requestedDelta.y + ) + ) +} + +internal fun desktopPdfZoomScrollBoundsWithCommitTargets( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + maxHorizontalScroll: Int, + currentVerticalScroll: Int? = null, + maxVerticalScroll: Int? = null +): DesktopPdfZoomScrollBounds { + return DesktopPdfZoomScrollBounds( + currentHorizontalScroll = currentHorizontalScroll, + maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0), + currentVerticalScroll = currentVerticalScroll, + maxVerticalScroll = maxVerticalScroll?.let { + maxOf(it, preview?.commitTargetVerticalScroll ?: 0) + } + ) +} + +internal fun desktopPdfSinglePageLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + pageCanvasSize: IntSize, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float +): DesktopPdfSinglePageLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val pageWidth = pageCanvasSize.width.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val pageX = if (pageWidth <= contentWidth) { + ((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val pageY = paddingPx - safeVerticalScroll.toFloat() + return DesktopPdfSinglePageLayoutPrediction( + rootOffset = Offset( + x = viewportRootOffset.x + pageX, + y = viewportRootOffset.y + pageY + ), + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfSpreadLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + visiblePageIndices: List, + pageCanvasSizes: Map, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float, + pageGapPx: Float +): DesktopPdfSpreadLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (visiblePageIndices.isEmpty()) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + if (!pageGapPx.isFinite() || pageGapPx < 0f) return null + val pageSizes = visiblePageIndices.map { pageIndex -> + val pageSize = pageCanvasSizes[pageIndex] ?: return null + if (pageSize.width <= 0 || pageSize.height <= 0) return null + pageSize + } + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val rowWidth = pageSizes.sumOf { it.width }.toFloat() + + (pageGapPx * (pageSizes.size - 1).coerceAtLeast(0)) + val rowHeight = pageSizes.maxOf { it.height }.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val rowX = if (rowWidth <= contentWidth) { + ((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val rowY = paddingPx - safeVerticalScroll.toFloat() + var pageX = viewportRootOffset.x + rowX + val pageY = viewportRootOffset.y + rowY + val roots = visiblePageIndices.mapIndexed { index, pageIndex -> + val root = Offset(pageX, pageY) + pageX += pageSizes[index].width.toFloat() + pageGapPx + pageIndex to root + }.toMap() + return DesktopPdfSpreadLayoutPrediction( + pageRootOffsets = roots, + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset: Offset, + oldPageRootOffset: Offset?, + currentAnchorPageRootOffset: Offset, + anchor: Offset?, + oldZoom: Float, + newZoom: Float, + currentZoom: Float, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Offset? { + if (oldPageRootOffset == null || anchor == null) return null + if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null + val pageDelta = desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffset, + oldPageRootOffset = oldPageRootOffset, + currentPageRootOffset = currentAnchorPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) ?: return null + val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds) + return Offset( + x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(), + y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat() + ) +} + internal fun desktopPdfZoomPreviewPivotFraction( viewportRootOffset: Offset, pageRootOffset: Offset, @@ -167,14 +469,39 @@ internal fun Modifier.desktopPdfZoomPreviewLayer( currentZoom: Float, viewportRootOffset: Offset, pageRootOffset: Offset, - pageCanvasSize: IntSize + pageCanvasSize: IntSize, + commitPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null ): 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 commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} " + + "h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " + + "v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}" + } val transformOrigin = activePreview.anchor?.let { anchor -> desktopPdfZoomPreviewPivotFraction( viewportRootOffset = viewportRootOffset, @@ -188,6 +515,8 @@ internal fun Modifier.desktopPdfZoomPreviewLayer( return graphicsLayer { scaleX = previewScale scaleY = previewScale + translationX = commitTranslation?.x ?: 0f + translationY = commitTranslation?.y ?: 0f this.transformOrigin = transformOrigin } } @@ -196,13 +525,38 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( preview: DesktopPdfZoomPreview?, currentZoom: Float, viewportRootOffset: Offset, - pageRootOffset: Offset + pageRootOffset: Offset, + anchorPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null ): 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 commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" + + "${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" + + "${scrollBounds?.maxVerticalScroll ?: "none"}" + } val translation = activePreview.anchor?.let { anchor -> desktopPdfDocumentZoomPreviewTranslation( viewportRootOffset = viewportRootOffset, @@ -214,8 +568,8 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( return graphicsLayer { scaleX = previewScale scaleY = previewScale - translationX = translation.x - translationY = translation.y + translationX = translation.x + (commitTranslation?.x ?: 0f) + translationY = translation.y + (commitTranslation?.y ?: 0f) transformOrigin = TransformOrigin(0f, 0f) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt index 6cc96bd..a2c46ac 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPlatformPaths.kt @@ -24,14 +24,6 @@ internal data class DesktopPlatform( 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" @@ -140,7 +132,7 @@ private fun xdgBase( ): File { return env(envName) ?.takeIf { it.isNotBlank() } + ?.takeIf { it.startsWith("/") } ?.let(::File) - ?.takeIf { it.isAbsolute } ?: File(userHome, fallbackRelativePath) } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt index dc2ed64..4c533ef 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Verified @@ -55,7 +54,7 @@ internal fun DesktopProScreen( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon(Icons.Default.Star, contentDescription = null, modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.primary) Column(Modifier.weight(1f)) { - Text(readerString("desktop_pro_and_credits", "Pro and credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(readerString("desktop_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text( readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."), style = MaterialTheme.typography.bodyMedium, @@ -73,7 +72,7 @@ internal fun DesktopProScreen( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Icon(Icons.Default.Verified, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(readerString("desktop_account", "Account"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) } if (user == null) { Text( @@ -92,11 +91,13 @@ internal fun DesktopProScreen( Text(readerString("drawer_sign_in", "Sign in with Google")) } } else { - Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) - user.email?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) + user.email?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } OutlinedButton(onClick = onRefresh, enabled = !isBusy) { Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(8.dp)) @@ -107,33 +108,26 @@ internal fun DesktopProScreen( } } } - statusMessage?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon(Icons.Default.Cloud, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(readerString("desktop_access", "Desktop access"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - } - Text( - if (isProUser) { - readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") - } else { - readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") - }, - style = MaterialTheme.typography.titleMedium - ) - Text(readerString("desktop_credits_available_format", "%1\$d credits available", credits), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) HorizontalDivider() + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + DesktopAccountValue( + label = readerString("desktop_plan", "Plan"), + value = if (isProUser) { + readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") + } else { + readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") + }, + modifier = Modifier.weight(1f) + ) + DesktopAccountValue( + label = readerString("credits_tab", "Credits"), + value = readerString("desktop_credits_available_format", "%1\$d credits available", credits), + modifier = Modifier.weight(1f) + ) + } + Text( readerString( "desktop_pro_purchase_android_desc", @@ -142,9 +136,25 @@ internal fun DesktopProScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + statusMessage?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } Spacer(Modifier.height(12.dp)) } } + +@Composable +private fun DesktopAccountValue( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProfileAvatar.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProfileAvatar.kt new file mode 100644 index 0000000..8f34ef3 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopProfileAvatar.kt @@ -0,0 +1,114 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +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.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 androidx.compose.foundation.shape.CircleShape +import com.aryan.reader.shared.UserData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage + +@Composable +internal fun DesktopProfileAvatar( + user: UserData, + modifier: Modifier = Modifier +) { + val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() } + var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) } + + LaunchedEffect(photoUrl) { + bitmap = if (photoUrl == null) { + null + } else { + withContext(Dispatchers.IO) { + DesktopProfileAvatarCache.load(photoUrl) + } + } + } + + val imageBitmap = bitmap + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier.clip(CircleShape) + ) + } else { + DesktopProfileAvatarFallback(user = user, modifier = modifier) + } +} + +@Composable +private fun DesktopProfileAvatarFallback( + user: UserData, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + val initial = (user.displayName ?: user.email) + ?.trim() + ?.firstOrNull() + ?.uppercase() + if (initial != null) { + Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } else { + Icon(Icons.Default.AccountCircle, contentDescription = null) + } + } + } +} + +private object DesktopProfileAvatarCache { + private const val MaxEntries = 24 + + private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MaxEntries + } + } + + fun peek(url: String): ImageBitmap? { + return synchronized(cache) { cache[url] } + } + + fun load(url: String): ImageBitmap? { + peek(url)?.let { return it } + val bitmap = runCatching { + DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap() + }.getOrNull() ?: return null + + synchronized(cache) { + cache[url] = 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/DesktopReaderDefaults.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDefaults.kt new file mode 100644 index 0000000..ebda3be --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDefaults.kt @@ -0,0 +1,91 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.reader.ReaderPageSpreadMode +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings + +internal const val DesktopReaderDefaultsVersion = 1 + +internal enum class DesktopReaderSettingsEngine { + TEXT, + PDF +} + +internal val DesktopDefaultTextReaderSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal val DesktopDefaultPdfReaderSettings = ReaderSettings( + themeId = "no_theme", + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? { + return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) { + ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT + null -> null + } +} + +internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean { + return type.desktopReaderSettingsEngine() == engine +} + +internal fun List.withDesktopReaderEngineSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): List { + return map { book -> + if (book.usesDesktopReaderSettingsEngine(engine)) { + book.copy(readerSettings = settings) + } else { + book + } + } +} + +internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): SharedReaderScreenState { + val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) { + settings.toDesktopPdfReaderSettings() + } else { + settings + } + return when (engine) { + DesktopReaderSettingsEngine.TEXT -> copy( + readerDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + DesktopReaderSettingsEngine.PDF -> copy( + pdfReaderDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + } +} + +internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode { + return when (readingMode) { + ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION + ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL + } +} + +internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode { + return when (this) { + PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED + PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt index 84f1647..2fe3dae 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt @@ -1,14 +1,26 @@ 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.ReaderLocator private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" +private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle" private const val PdfLinkLogTag = "EpistemePdfLink" +private const val PdfChromeTapLogTag = "EpistemePdfChromeTap" private const val EpubLinkLogTag = "EpistemeEpubLink" private const val EpubPaginationLogTag = "EpistemeEpubPagination" +private const val EpubCutoffLogTag = "EpistemeEpubCutoff" private const val ReaderGapLogTag = "EpistemeReaderGap" private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" +private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow" +private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap" +private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace" +private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose" +private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView" +private const val WebViewLayoutLogTag = "EpistemeWebViewLayout" +private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch" internal fun logPdfSelection(message: String) { } @@ -21,10 +33,26 @@ internal fun logPdfZoomPerf(message: () -> String) { logDesktopDiagnostic(PdfZoomPerfLogTag, message) } +internal fun logPdfZoomSettle(message: String) { + logDesktopDiagnostic(PdfZoomSettleLogTag) { message } +} + +internal fun logPdfZoomSettle(message: () -> String) { + logDesktopDiagnostic(PdfZoomSettleLogTag, message) +} + internal fun logPdfLink(message: String) { logDesktopDiagnostic(PdfLinkLogTag) { message } } +internal fun logPdfChromeTap(message: String) { + logDesktopDiagnostic(PdfChromeTapLogTag) { message } +} + +internal fun logPdfChromeTap(message: () -> String) { + logDesktopDiagnostic(PdfChromeTapLogTag, message) +} + internal fun logEpubLink(message: String) { logDesktopDiagnostic(EpubLinkLogTag) { message } } @@ -33,6 +61,10 @@ internal fun logEpubPagination(message: String) { logDesktopDiagnostic(EpubPaginationLogTag) { message } } +internal fun logEpubCutoff(message: String) { + logDesktopDiagnostic(EpubCutoffLogTag) { message } +} + internal fun logReaderGap(message: String) { logDesktopDiagnostic(ReaderGapLogTag) { message } } @@ -41,6 +73,38 @@ internal fun logEpubSelectionDebug(message: String) { logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } } +internal fun logEpubHighlightFlow(message: String) { + logDesktopDiagnostic(EpubHighlightFlowLogTag) { message } +} + +internal fun logDesktopHighlightMap(message: String) { + logDesktopDiagnostic(DesktopHighlightMapLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: () -> String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag, message) +} + +internal fun logDesktopReaderClose(message: String) { + logDesktopDiagnostic(DesktopReaderCloseLogTag) { message } +} + +internal fun logDesktopWebView2(message: String) { + logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message } +} + +internal fun logWebViewLayoutDiag(message: String) { + logDesktopDiagnostic(WebViewLayoutLogTag) { message } +} + +internal fun logReaderModeSwitch(message: String) { + logDesktopDiagnostic(ReaderModeSwitchLogTag) { message } +} + internal fun DesktopPdfLinkTarget.formatLogTarget(): String { return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" } @@ -54,6 +118,11 @@ internal fun Offset?.formatLogOffset(): String { return "${x.formatLogFloat()},${y.formatLogFloat()}" } +internal fun IntOffset?.formatLogIntOffset(): String { + if (this == null) return "none" + return "${this.x},${this.y}" +} + internal fun IntSize.formatLogSize(): String { return "${width}x${height}" } @@ -66,3 +135,12 @@ internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" } + +internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " + + "cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\"" +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpenTrace.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpenTrace.kt new file mode 100644 index 0000000..9752419 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpenTrace.kt @@ -0,0 +1,29 @@ +package com.aryan.reader.desktop + +internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace" + +internal fun logDesktopReaderOpenTrace(message: () -> String) { + logDesktopDiagnostic(DesktopReaderOpenTraceTag, message) +} + +internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L +} + +internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return startedAtNanos.elapsedOpenTraceMs(nowNanos) +} + +internal fun DesktopReaderOpening.openTracePrefix(event: String): String { + return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " + + "title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}" +} + +internal fun DesktopReaderOpenResult.openTraceKind(): String { + return when (this) { + is DesktopReaderOpenResult.Failure -> "failure" + is DesktopReaderOpenResult.PasswordRequired -> "password_required" + is DesktopReaderOpenResult.Pdf -> "pdf" + is DesktopReaderOpenResult.Text -> "text" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt index c0ee262..2bdba86 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt @@ -10,7 +10,8 @@ internal data class DesktopReaderOpening( val title: String, val formatLabel: String, val returnTab: SharedAppTab, - val password: String? = null + val password: String? = null, + val startedAtNanos: Long = System.nanoTime() ) internal sealed interface DesktopReaderOpenResult { diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt index f5ee0ff..08fe7b2 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -49,11 +48,8 @@ import com.aryan.reader.shared.ReaderAiByokSettings import com.aryan.reader.shared.ReaderAiModelOption import com.aryan.reader.shared.ReaderAiModelOptions import com.aryan.reader.shared.ReaderAiResultState -import com.aryan.reader.shared.ReaderAutoScrollState import com.aryan.reader.shared.ReaderCloudTtsVoices import com.aryan.reader.shared.ReaderExtrasState -import com.aryan.reader.shared.ReaderExternalLookupAction -import com.aryan.reader.shared.ReaderTtsReadScope import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.maskedReaderAiKey import com.aryan.reader.shared.ui.SharedMarkdownText @@ -197,7 +193,7 @@ internal fun DesktopAiByokSettingsDialog( DesktopSavedAiKeyRow( label = readerString("provider_gemini", "Gemini"), keyValue = sanitized.geminiKey, - onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) } + onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) } ) DesktopSavedAiKeyRow( label = readerString("provider_groq", "Groq"), @@ -250,26 +246,6 @@ internal fun DesktopAiByokSettingsDialog( HorizontalDivider() - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("options_show_ai_in_reader", "Show AI in reader"), style = MaterialTheme.typography.titleMedium) - Text( - readerString( - "desktop_show_ai_in_reader_desc", - "Matches the Android hide toggle for smart dictionary, summaries, and recaps." - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = !sanitized.hideReaderAiFeatures, - onCheckedChange = { enabled -> - onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled)) - } - ) - } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium) @@ -320,31 +296,6 @@ internal fun DesktopAiByokSettingsDialog( options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } ) - Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - ReaderCloudTtsVoices.chunked(3).forEach { rowVoices -> - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - rowVoices.forEach { voice -> - FilterChip( - selected = sanitized.ttsSpeakerId == voice.id, - onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, - label = { - Column { - Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - voice.description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - ) - } - } - } - } } }, confirmButton = { @@ -405,53 +356,21 @@ private fun DesktopAiModelSelector( } @Composable -internal fun DesktopPdfExtrasPanel( - pageText: String, +internal fun DesktopPdfTtsPanel( extrasState: ReaderExtrasState, aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, cloudTtsFeatureAvailable: Boolean, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, ttsReplacementPreferences: ReaderTtsReplacementPreferences, ttsReplacementBookId: String, onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit ) { val settings = aiByokSettings.sanitized() - val autoScroll = extrasState.autoScroll.sanitized() Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text(readerString("desktop_extras", "Extras"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - 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) { - Text(readerString("menu_auto_scroll", "Auto Scroll"), modifier = Modifier.weight(1f)) - Switch( - checked = autoScroll.enabled, - onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) } - ) - } - Slider( - value = autoScroll.speed, - onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) }, - valueRange = 12f..160f - ) + Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused if (cloudTtsFeatureAvailable) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -476,40 +395,38 @@ internal fun DesktopPdfExtrasPanel( Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - TextButton( - enabled = settings.isCloudTtsAvailable || ttsBusy, - onClick = { - if (ttsBusy) { - onCloudTtsStop() - } else { - onCloudTtsStart(ReaderTtsReadScope.BOOK) - } - } - ) { - Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read")) - } } - if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold) + if (ttsBusy) { + Text( + readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onCloudTtsPauseResume) { - Text(if (extrasState.cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause")) + ReaderCloudTtsVoices.forEach { voice -> + FilterChip( + selected = settings.ttsSpeakerId == voice.id, + enabled = !ttsBusy, + onClick = { onCloudTtsVoiceChange(voice.id) }, + label = { + Column { + Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + voice.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) } } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } - ) { - Text(readerString("desktop_page", "Page")) - } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } - ) { - Text(readerString("desktop_from_here", "From here")) - } - } val cacheSummary = extrasState.cloudTts.cacheSummary if (cacheSummary.hasCachedAudio) { Text( @@ -529,12 +446,5 @@ internal fun DesktopPdfExtrasPanel( bookId = ttsReplacementBookId, onPreferencesChange = onTtsReplacementPreferencesChange ) - if (settings.areReaderAiFeaturesAvailable && onOpenAiHub != null) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onOpenAiHub) { - Text(readerString("desktop_ai_hub", "AI hub")) - } - } - } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt index 44cb459..1515dc3 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderScreen.kt @@ -1,5 +1,7 @@ package com.aryan.reader.desktop +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ColumnScope @@ -10,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.rememberCoroutineScope @@ -17,6 +20,8 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity @@ -34,19 +39,23 @@ import com.aryan.reader.shared.ReaderAutoScrollState import com.aryan.reader.shared.ReaderExtrasState import com.aryan.reader.shared.ReaderExternalLookupAction import com.aryan.reader.shared.ReaderHighlightPalette +import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.ReaderToolbarPreferences import com.aryan.reader.shared.ReaderTtsChunk import com.aryan.reader.shared.ReaderTtsReadScope import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTheme import com.aryan.reader.shared.reader.ReaderEngine import com.aryan.reader.shared.reader.ReaderImageReference 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.ReaderViewportSpec import com.aryan.reader.shared.reader.SharedEpubPaginationCache import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator +import com.aryan.reader.shared.reader.isRightToLeftPaginationEnabled import com.aryan.reader.shared.reader.layoutSignature import com.aryan.reader.shared.reduce import com.aryan.reader.shared.ui.DesktopEpubNativeImage @@ -54,7 +63,12 @@ import com.aryan.reader.shared.ui.ReaderContentRenderPlan import com.aryan.reader.shared.ui.SharedNativePaginatedReader import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction import com.aryan.reader.shared.ui.SharedReaderScreen +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.awt.EventQueue +import java.awt.Window import java.awt.event.KeyEvent as AwtKeyEvent @Composable @@ -64,8 +78,12 @@ internal fun DesktopReaderScreen( onSessionChange: (ReaderSessionState) -> Unit, onReturnToLibrary: (() -> Unit)? = null, onFullscreenChange: (Boolean) -> Unit = {}, + readerAwtWindow: Window? = null, toolbarPreferences: ReaderToolbarPreferences, onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, highlightPalette: ReaderHighlightPalette, onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, ttsReplacementPreferences: ReaderTtsReplacementPreferences, @@ -80,13 +98,13 @@ internal fun DesktopReaderScreen( onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, onAiAction: (ReaderAiFeature, String) -> Unit, onAiResultDismiss: () -> Unit, - onCloudTtsToggle: (String) -> Unit, + onCloudTtsToggle: (String, ReaderLocator?) -> Unit, onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, onCloudTtsPauseResume: () -> Unit, onCloudTtsStop: () -> Unit, onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, onOpenAiHub: (() -> Unit)? = null, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, onDownloadReaderImage: (ReaderImageReference) -> Unit, readerTextureDataUri: (String) -> String?, readerCustomTextureIds: List, @@ -119,6 +137,18 @@ internal fun DesktopReaderScreen( cacheWriteScope = paginationCacheWriteScope ) } + LaunchedEffect(session.reader.book.id) { + logDesktopReaderOpenTrace { + "event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " + + "chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " + + "bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}" + } + } var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } val paginationLayoutSignature = session.reader.settings.layoutSignature() val paginationContentSignature = remember(session.reader.book) { @@ -152,16 +182,40 @@ internal fun DesktopReaderScreen( var completedMeasuredPaginationRequest by remember(session.reader.book.id) { mutableStateOf(null) } + var completedMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } + var warmMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var warmMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } var runningMeasuredPaginationRequest by remember(session.reader.book.id) { mutableStateOf(null) } - val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED || - (measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest) + val measuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = completedMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = completedMeasuredPaginationPages + ) + val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = warmMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = warmMeasuredPaginationPages + ) + val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay( + readingMode = session.reader.settings.readingMode, + measuredPagesApplied = measuredPaginationPagesApplied + ) 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 desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState()) val currentReaderFullscreen by rememberUpdatedState(isFullscreen) val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) @@ -175,9 +229,12 @@ internal fun DesktopReaderScreen( onDismiss = { externalLinkDialogUrl = null } ) - fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { - val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false + fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { val currentSession = latestSession + val action = event.desktopReaderKeyNavigationOrNull( + fullscreen = isFullscreen, + rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled() + ) ?: return false val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine) if (nextSession == null) { if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { @@ -188,6 +245,43 @@ internal fun DesktopReaderScreen( } return true } + + fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (latestSession.isSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + setReaderFullscreen(false) + return true + } + return false + } + return handleReaderAwtKeyEvent(event) + } + + fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + val action = when (event.keyCode) { + AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH + AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH + else -> return false + } + val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false + latestOnSessionChange(nextSession) + return true + } + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null, + allowChromeModalWindows = true, + onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null && !session.isSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handleReaderAwtKeyEvent(event) } + ) + DesktopReaderFullscreenKeyEffect( enabled = isFullscreen && externalLinkDialogUrl == null, onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } @@ -196,6 +290,9 @@ internal fun DesktopReaderScreen( LaunchedEffect(session.reader.settings.readingMode) { if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { completedMeasuredPaginationRequest = null + completedMeasuredPaginationPages = emptyList() + warmMeasuredPaginationRequest = null + warmMeasuredPaginationPages = emptyList() runningMeasuredPaginationRequest = null } } @@ -220,15 +317,113 @@ internal fun DesktopReaderScreen( ) 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 cacheProbeStartedAt = System.nanoTime() + val cacheProbeSettings = latestSession.reader.settings + val cachedPages = if ( + cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED && + cacheProbeSettings.layoutSignature() == request.layoutSignature + ) { + withContext(Dispatchers.Default) { + epubPaginationCache.loadMemory( + book = session.reader.book, + settings = cacheProbeSettings, + viewport = request.viewport, + density = request.density.density, + fontScale = request.density.fontScale + ) + } + } else { + null + } + val settingsAfterCacheProbe = latestSession.reader.settings + if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect + if (cachedPages != null) { + val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages) + logEpubPagination( + "cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " + + "layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "elapsedMs=${cacheProbeStartedAt.elapsedMillis()}" + ) + if (cacheLayoutChanged) { + val cacheApplySession = latestSession + latestOnSessionChange( + readerEngine.replacePages( + state = cacheApplySession, + pages = cachedPages, + reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession) + ) + ) + } + completedMeasuredPaginationPages = cachedPages + completedMeasuredPaginationRequest = request + return@LaunchedEffect + } + + val warmStartSession = latestSession + val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession) + val warmChapterIndex = warmAnchor?.chapterIndex + ?: warmStartSession.reader.currentPage?.chapterIndex + ?: 0 + val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0 + val warmStartedAt = System.nanoTime() + logEpubPagination( + "chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + val cachedWarmChapterPages = epubPaginationCache.loadChapter( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + density = request.density.density, + fontScale = request.density.fontScale + ) + val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) { + measuredPaginator.paginateChapterWindow( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + firstPageIndex = warmFirstPageIndex + ) + } + val warmPages = desktopPagesWithMeasuredChapter( + currentPages = warmStartSession.reader.pages, + chapterIndex = warmChapterIndex, + measuredChapterPages = warmChapterPages + ) + val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages) + logEpubPagination( + "chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " + + "chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " + + "elapsedMs=${warmStartedAt.elapsedMillis()}" + ) + if (warmLayoutChanged) { + logReaderModeSwitch( + "pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}" + ) + latestOnSessionChange( + readerEngine.replacePages( + state = warmStartSession, + pages = warmPages, + reflowAnchor = warmAnchor + ) + ) + warmMeasuredPaginationPages = warmPages + warmMeasuredPaginationRequest = request + } + val reflowStartSession = latestSession val reflowStartRequestId = reflowStartSession.navigationRequestId val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) + val settings = reflowStartSession.reader.settings + if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect logEpubPagination( "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + @@ -237,17 +432,35 @@ internal fun DesktopReaderScreen( "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 pages = withContext(Dispatchers.Default) { + measuredPaginator.paginate( + book = session.reader.book, + settings = settings, + viewport = request.viewport, + readCache = true + ) + } 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}" ) + val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex) + ?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" } + ?: "none" + logReaderModeSwitch( + "pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " + + "currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails" + ) if (layoutChanged) { + logReaderModeSwitch( + "pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}" + ) latestOnSessionChange( readerEngine.replacePages( state = latestSession, @@ -258,8 +471,16 @@ internal fun DesktopReaderScreen( ) } if (pages.isNotEmpty()) { + completedMeasuredPaginationPages = pages completedMeasuredPaginationRequest = request } + } catch (error: Throwable) { + if (error is CancellationException) throw error + logEpubPagination( + "reflow_failed book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "error=\"${error.message.orEmpty().logPreview(300)}\"" + ) } finally { if (runningMeasuredPaginationRequest == request) { runningMeasuredPaginationRequest = null @@ -267,16 +488,45 @@ internal fun DesktopReaderScreen( } } - val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text -> + LaunchedEffect( + measuredPaginationRequest, + completedMeasuredPaginationRequest, + completedMeasuredPaginationPages, + session.reader.pages + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + val measuredPages = completedMeasuredPaginationPages + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect + if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect + val currentVisiblePageDetails = session.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " + + "currentVisible=$currentVisiblePageDetails" + ) + onSessionChange( + readerEngine.replacePages( + state = session, + pages = measuredPages, + reflowAnchor = readerEngine.reflowAnchorFor(session) + ) + ) + } + + val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> val settings = aiByokSettings.sanitized() when (action) { DesktopReaderSelectionAction.DEFINE -> { if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) } DesktopReaderSelectionAction.SPEAK -> { - if (settings.isCloudTtsAvailable) onCloudTtsToggle(text) + if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator) } DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) + DesktopReaderSelectionAction.PALETTE -> Unit } } val nativeSelectionActions = buildSet { @@ -285,14 +535,14 @@ internal fun DesktopReaderScreen( if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) } - val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text -> + val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> when (action) { SharedNativeReaderSelectionAction.DEFINE -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text) + handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator) SharedNativeReaderSelectionAction.SPEAK -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text) + handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator) SharedNativeReaderSelectionAction.SEARCH -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text) + handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator) } } val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link -> @@ -340,6 +590,9 @@ internal fun DesktopReaderScreen( onFullscreenChange = ::setReaderFullscreen, toolbarPreferences = toolbarPreferences, onToolbarPreferencesChange = onToolbarPreferencesChange, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, highlightPalette = highlightPalette, onHighlightPaletteChange = onHighlightPaletteChange, ttsReplacementPreferences = ttsReplacementPreferences, @@ -347,7 +600,7 @@ internal fun DesktopReaderScreen( onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, onPickCustomFont = onPickCustomFont, customFonts = customFonts, - readerExtrasState = readerExtrasState, + readerExtrasState = desktopReaderExtrasState, aiByokSettings = aiByokSettings, externalLookupAvailable = externalLookupAvailable, cloudTtsControlsAvailable = cloudTtsControlsAvailable, @@ -359,8 +612,8 @@ internal fun DesktopReaderScreen( onCloudTtsPauseResume = onCloudTtsPauseResume, onCloudTtsStop = onCloudTtsStop, onCloudTtsClearCache = onCloudTtsClearCache, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, onOpenAiHub = onOpenAiHub, - onAutoScrollChange = onAutoScrollChange, onDownloadReaderImage = onDownloadReaderImage, readerImagePreviewContent = { image, previewModifier -> DesktopEpubNativeImage( @@ -369,35 +622,143 @@ internal fun DesktopReaderScreen( ) }, readerTextureDataUri = readerTextureDataUri, + readerTexturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = onImportReaderTexture, bottomChromeExtraContent = bottomChromeExtraContent, useDetachedChromeLayer = useDetachedChromeLayer, useDetachedPanelLayer = useDetachedPanelLayer - ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onChromeActivity -> - Surface( - color = renderPlan.background, - shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.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)}" + ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity -> + val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey() + val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady) + val readerModeSwitchLayoutModifier = + if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) { + Modifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " + + "paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}" ) - if (next != readerViewport) { - logEpubPagination( - "viewport_changed width=${next.widthPx} height=${next.heightPx} " + - "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" - ) - readerViewport = next - } } + } else { + Modifier + } + val readerSurfaceModifier = Modifier + .fillMaxWidth() + .weight(1f) + .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)}" + ) + logEpubCutoff( + "cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidthSetting=${session.reader.settings.pageWidth}" + ) + logWebViewLayoutDiag( + "compose_reader_surface size=${size.width}x${size.height} " + + "renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " + + "mode=${session.reader.settings.readingMode} " + + "fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " + + "lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " + + "paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}" + ) + logDesktopReaderOpenTrace { + "event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} 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 + } + } + LaunchedEffect( + renderPlanModeKey, + session.reader.settings.readingMode, + paginatedLayoutReady ) { + logReaderModeSwitch( + "surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " + + "completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " + + "warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " + + "completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " + + "measuredPageCount=${completedMeasuredPaginationPages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " + + "fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey" + ) + logDesktopReaderOpenTrace { + "event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " + + "paginatedReady=$paginatedLayoutReady" + } + if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) { + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_surface_state_ready_$paginatedLayoutReady" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request") + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_surface_state_ready_$paginatedLayoutReady" + ) + DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + logReaderModeSwitch( + "native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " + + "visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}" + ) + logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms") + } + } else { + logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey") + } + } + DisposableEffect(renderPlanModeKey) { + logReaderModeSwitch( + "surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey") + onDispose { + logReaderModeSwitch( + "surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey") + } + } + @Composable + fun ReaderSurfaceContent() { if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { DesktopEpubPaginationPreparing( active = runningMeasuredPaginationRequest != null, @@ -406,14 +767,41 @@ internal fun DesktopReaderScreen( } else { when (renderPlan) { is ReaderContentRenderPlan.WebDocument -> { - if (webViewRuntimeState.initialized) { + val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState) + LaunchedEffect( + renderPlan.html, + canRenderWebDocument, + webViewRuntimeState, + webViewNetworkAccessEnabled + ) { + logDesktopWebView2( + "reader_screen_web_document canRender=$canRenderWebDocument " + + "backend=${desktopEpubWebViewBackend().logName} " + + "runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " + + "error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " + + "htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}" + ) + } + if (canRenderWebDocument) { DesktopEpubWebView( html = renderPlan.html, appearanceScript = renderPlan.appearanceScript, + highlightPaletteScript = renderPlan.highlightPaletteScript, navigationTarget = renderPlan.navigationTarget, highlights = renderPlan.highlights, onHighlightCreated = { highlight -> - onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) + logEpubHighlightFlow( + "state_reduce_start id=${highlight.id} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "page=${highlight.locator.pageIndex} textChars=${highlight.text.length}" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logEpubHighlightFlow( + "state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) }, onHighlightSelected = onHighlightSelected, isFullscreen = isFullscreen, @@ -427,11 +815,18 @@ internal fun DesktopReaderScreen( onSessionChange(nextSession) } }, - onSelectionAction = handleDesktopSelectionAction, + onSelectionAction = { payload -> + if (payload.action == DesktopReaderSelectionAction.PALETTE) { + onOpenHighlightPaletteManager() + } else { + handleDesktopSelectionAction(payload.action, payload.text, payload.locator) + } + }, onLinkClicked = handleDesktopEpubLinkClicked, onVisiblePageChanged = onVisiblePageChanged, onPointerActivity = onChromeActivity, networkAccessEnabled = webViewNetworkAccessEnabled, + backgroundColor = renderPlan.background, modifier = Modifier.fillMaxSize() ) } else { @@ -442,36 +837,157 @@ internal fun DesktopReaderScreen( } } 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() - ) + LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) { + val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "native_reader_render paginatedReady=$paginatedLayoutReady " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " + + "pageDetails=$pageDetails " + + "background=${renderPlan.background} foreground=${renderPlan.foreground}" + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}" + ) + } + ) { + 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, + onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, + onHighlightCreated = { highlight -> + logDesktopHighlightMap( + "native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logDesktopHighlightMap( + "native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + onReaderTap = onChromeActivity, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } } } } } + + key(readerSurfaceKey) { + if (renderPlan is ReaderContentRenderPlan.WebDocument) { + Box( + modifier = readerSurfaceModifier + .fillMaxSize() + .background(renderPlan.background) + ) { + ReaderSurfaceContent() + } + } else { + Surface( + color = renderPlan.background, + shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), + modifier = readerSurfaceModifier + .fillMaxSize() + .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .then(readerModeSwitchLayoutModifier) + ) { + ReaderSurfaceContent() + } + } + } } } +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "web" + is ReaderContentRenderPlan.NativePaginatedPages -> "native" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> + "desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}" + } +} + +private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) { + val targetWindow = this + EventQueue.invokeLater { + if (targetWindow == null) { + logReaderModeSwitch("awt_repaint_skip reason=$reason window=null") + return@invokeLater + } + if (!targetWindow.isDisplayable) { + logReaderModeSwitch( + "awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " + + "displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " + + "size=${targetWindow.width}x${targetWindow.height}" + ) + return@invokeLater + } + targetWindow.invalidate() + targetWindow.validate() + targetWindow.repaint() + (targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane -> + contentPane.invalidate() + contentPane.validate() + contentPane.repaint() + } + logReaderModeSwitch( + "awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " + + "visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " + + "showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}" + ) + } +} + +private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L) + +private fun Long.elapsedMillis(): Long { + return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L) +} + private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage { return SemanticImage( path = source, diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTexturePreview.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTexturePreview.kt new file mode 100644 index 0000000..73ba84d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTexturePreview.kt @@ -0,0 +1,43 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight + +@Composable +internal fun DesktopReaderTexturePreview( + textureId: String, + modifier: Modifier = Modifier +) { + val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } else { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + "Aa", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt index e28b413..8c09fa3 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt @@ -35,7 +35,7 @@ internal fun List.samePageLayoutAs(other: List): Boolean left.startOffset == right.startOffset && left.endOffset == right.endOffset && left.text.length == right.text.length && - left.semanticBlocks.size == right.semanticBlocks.size + left.semanticBlocks == right.semanticBlocks } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt index b1a874e..e19b13a 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderWindowState.kt @@ -1,19 +1,53 @@ package com.aryan.reader.desktop +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.ReaderCloudTtsState import com.aryan.reader.shared.ReaderExtrasState import com.aryan.reader.shared.RecapResult import com.aryan.reader.shared.SummarizationResult +import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSessionState import kotlinx.coroutines.Job +internal const val DesktopReaderWindowDefaultWidthDp = 1120f +internal const val DesktopReaderWindowDefaultHeightDp = 760f +internal val DesktopReaderWindowDefaultSize = DpSize( + DesktopReaderWindowDefaultWidthDp.dp, + DesktopReaderWindowDefaultHeightDp.dp +) + +internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement { + return when (placement) { + DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating + else -> toWindowPlacement() + } +} + +internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? { + if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null + return sanitized() +} + +internal fun shouldResetDesktopTextReaderWindowSurface( + previousMode: ReaderReadingMode, + currentMode: ReaderReadingMode, + usesNativeWebView: Boolean +): Boolean { + return usesNativeWebView && + previousMode == ReaderReadingMode.VERTICAL && + currentMode == ReaderReadingMode.PAGINATED +} + internal data class DesktopReaderWindowState( val id: String, val opening: DesktopReaderOpening, val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening, val focusRequestId: Long = 0L, - val fullscreen: Boolean = false + val fullscreen: Boolean = false, + val surfaceResetId: Long = 0L ) { val bookId: String get() = opening.bookId @@ -57,7 +91,6 @@ internal sealed interface DesktopReaderWindowContent { val isSummaryLoading: Boolean = false, val isRecapLoading: Boolean = false, val recapProgressMessage: String? = null, - val showCloudTtsSettings: Boolean = false, val ttsJob: Job? = null ) : DesktopReaderWindowContent } 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 26f63ce..677cfc8 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt @@ -1,18 +1,39 @@ package com.aryan.reader.desktop +import com.aryan.reader.shared.ReaderTtsChunk + private const val DesktopTtsLogTag = "EpistemeDesktopTts" +private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" +private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""") +private val DesktopTtsSensitiveLabelRegex = Regex( + """(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+""" +) internal fun logDesktopTts(message: String) { logDesktopDiagnostic(DesktopTtsLogTag) { message } } +internal fun logDesktopTtsStartTrace(message: () -> String) { + logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message) +} + +internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " + + "offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " + + "textChars=${text.length} spokenChars=${spokenText.length} " + + "text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\"" +} + internal fun Throwable.desktopTtsSummary(): String { val type = this::class.java.simpleName.ifBlank { "Throwable" } return "$type: ${message.orEmpty().desktopTtsPreview(220)}" } internal fun String.desktopTtsPreview(maxLength: Int = 120): String { - return replace(Regex("\\s+"), " ") + return replace(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "" } + .replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "" } + .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/DesktopWindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt index fc79d31..a2f4ed4 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowStateStore.kt @@ -151,5 +151,9 @@ internal class DesktopWindowStateStore( fun defaultWindowStateFile(): File { return File(desktopUserConfigRoot(), "window_state.json") } + + fun defaultReaderWindowStateFile(): File { + return File(desktopUserConfigRoot(), "reader_window_state.json") + } } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowsWebView2EpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowsWebView2EpubWebView.kt new file mode 100644 index 0000000..8ae5d46 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopWindowsWebView2EpubWebView.kt @@ -0,0 +1,1910 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.EpubAnnotationSerializer +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.ui.ReaderContentNavigationTarget +import com.aryan.reader.shared.ui.readerString +import kotlinx.coroutines.delay +import org.eclipse.swt.SWT +import org.eclipse.swt.awt.SWT_AWT +import org.eclipse.swt.browser.Browser +import org.eclipse.swt.browser.BrowserFunction +import org.eclipse.swt.browser.LocationEvent +import org.eclipse.swt.browser.LocationListener +import org.eclipse.swt.browser.ProgressAdapter +import org.eclipse.swt.browser.ProgressEvent +import org.eclipse.swt.widgets.Display +import org.eclipse.swt.widgets.Shell +import java.awt.Canvas +import java.awt.EventQueue +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +@Composable +internal fun DesktopNativeSwtEpubWebView( + html: String, + appearanceScript: String, + highlightPaletteScript: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit = {}, + networkAccessEnabled: Boolean, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + val backend = remember { desktopEpubWebViewBackend() } + if (backend == DesktopEpubWebViewBackend.UNSUPPORTED) { + DesktopNativeWebViewError( + backend = backend, + message = desktopNativeWebViewUnavailableMessage(backend), + modifier = modifier.fillMaxSize() + ) + return + } + val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) + val bridgeHandlers = rememberDesktopEpubBridgeHandlers( + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity + ) + val bridgeHandlersByMethod = remember(bridgeHandlers) { + bridgeHandlers.associateBy { it.methodName } + } + val hostBackground = remember(backgroundColor) { backgroundColor.toAwtColor() } + val panel = remember { DesktopWindowsWebView2Panel(hostBackground, backend) } + val composeDensity = LocalDensity.current + var loaded by remember { mutableStateOf(false) } + var loadProgress by remember { mutableFloatStateOf(-1f) } + var errorMessage by remember { mutableStateOf(null) } + val webViewHtml = remember(html, networkAccessEnabled) { + html.withDesktopWebView2Bootstrap(networkAccessEnabled = networkAccessEnabled) + } + + DisposableEffect(panel) { + onDispose { + logDesktopWebView2("compose_dispose panel=${panel.instanceId}") + panel.disposeWebView(waitForSwtDisposal = true) + } + } + + LaunchedEffect(hostBackground) { + panel.updateBackground(hostBackground) + } + + Box( + modifier = modifier.fillMaxSize().onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_webview_box panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded network=$networkAccessEnabled navMode=${navigationTarget.readingMode} " + + "composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) { + SwingPanel( + background = backgroundColor, + factory = { panel }, + update = { currentPanel -> + currentPanel.configure( + bridgeHandlersByMethod = bridgeHandlersByMethod, + networkAccessEnabled = networkAccessEnabled, + onLinkIntercepted = { link -> latestOnLinkClicked(link) }, + onLoadStateChanged = { isLoaded, progress -> + loaded = isLoaded + loadProgress = progress + }, + onError = { message -> + errorMessage = message + loaded = false + loadProgress = -1f + } + ) + }, + modifier = Modifier + .matchParentSize() + .onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_swing_panel panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) + + LaunchedEffect(webViewHtml) { + loaded = false + loadProgress = -1f + errorMessage = null + logDesktopWebView2( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} rawHash=${html.hashCode()} wrappedHash=${webViewHtml.hashCode()} " + + "network=$networkAccessEnabled" + ) + logWebViewLayoutDiag( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} navMode=${navigationTarget.readingMode} " + + "background=${backgroundColor.toArgb()}" + ) + panel.loadHtml(webViewHtml) + } + + LaunchedEffect(loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_loaded panel=${panel.instanceId} action=install_key_navigation") + panel.executeJavaScript(DesktopEpubKeyNavigationScript) + } + + LaunchedEffect(isFullscreen, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=fullscreen value=$isFullscreen") + panel.executeJavaScript( + "window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};" + + "window.dispatchEvent(new Event('resize'));" + ) + panel.relayoutWebView("fullscreen_state_changed") + DesktopWebView2FullscreenRelayoutDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + panel.relayoutWebView("fullscreen_state_changed_after_${delayMillis}ms") + panel.executeJavaScript("window.dispatchEvent(new Event('resize'));") + } + } + + LaunchedEffect(html, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=desktop_finished") + panel.executeJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") + } + + LaunchedEffect(appearanceScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=appearance chars=${appearanceScript.length} hash=${appearanceScript.hashCode()}" + ) + panel.executeJavaScript(appearanceScript + "\n" + desktopWebView2DocumentProbeScript("appearance_applied")) + } + + LaunchedEffect(highlightPaletteScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=highlight_palette chars=${highlightPaletteScript.length} " + + "hash=${highlightPaletteScript.hashCode()}" + ) + panel.executeJavaScript(highlightPaletteScript) + } + + LaunchedEffect( + navigationTarget.requestId, + navigationTarget.readingMode, + loaded + ) { + if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (!loaded) return@LaunchedEffect + val locator = navigationTarget.locator ?: return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=scroll_locator request=${navigationTarget.requestId} " + + "chapter=${locator.chapterIndex} page=${locator.pageIndex}" + ) + panel.executeJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") + } + + LaunchedEffect( + navigationTarget.ttsRequestId, + navigationTarget.ttsLocator, + navigationTarget.readingMode, + loaded + ) { + if (!loaded) 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);" + } + panel.executeJavaScript(command) + } + + LaunchedEffect(highlights, loaded) { + if (!loaded) return@LaunchedEffect + val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=apply_highlights count=${highlights.size} chars=${highlightsJson.length}" + ) + panel.executeJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") + } + + if (errorMessage != null) { + DesktopNativeWebViewError( + backend = backend, + message = errorMessage.orEmpty(), + modifier = Modifier.fillMaxSize() + ) + } else if (!loaded) { + if (loadProgress in 0f..1f) { + LinearProgressIndicator( + progress = { loadProgress }, + modifier = Modifier.fillMaxWidth() + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun DesktopNativeWebViewError( + backend: DesktopEpubWebViewBackend, + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = readerString( + "desktop_native_webview_start_error", + "%1\$s could not start: %2\$s", + backend.displayName, + message + ), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } +} + +private class DesktopWindowsWebView2Panel( + initialBackground: java.awt.Color, + private val backend: DesktopEpubWebViewBackend +) : Canvas() { + val instanceId: Int = nextDesktopWebView2InstanceId() + + @Volatile + private var bridgeHandlersByMethod: Map = emptyMap() + + @Volatile + private var networkAccessEnabled: Boolean = true + + @Volatile + private var onLinkIntercepted: (DesktopEpubLinkClick) -> Unit = {} + + @Volatile + private var onLoadStateChanged: (Boolean, Float) -> Unit = { _, _ -> } + + @Volatile + private var onError: (String) -> Unit = {} + + private var controller: DesktopWindowsWebView2Controller? = null + private var requestedHtml: String? = null + + @Volatile + private var lastLoadStartedAtNanos: Long = 0L + + val hasController: Boolean get() = controller != null + + @Volatile + private var disposeInProgress = false + + @Volatile + private var hostWindowClosing = false + + private var hostWindow: java.awt.Window? = null + private var hostWindowListener: WindowAdapter? = null + + init { + background = initialBackground + updateModeSwitchPanelState("init") + addComponentListener( + object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) { + logDesktopWebView2("panel_resized panel=$instanceId size=${width}x${height}") + logWebViewLayoutDiag( + "awt_canvas_resized panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_resized") + controller?.resize(width, height, reason = "component_resized") + } + + override fun componentMoved(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_moved panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_moved") + controller?.resize(width, height, reason = "component_moved") + } + + override fun componentShown(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_shown panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_shown") + controller?.resize(width, height, reason = "component_shown") + } + } + ) + } + + fun relayoutWebView(reason: String) { + EventQueue.invokeLater { + updateModeSwitchPanelState("relayout_$reason") + logWebViewLayoutDiag( + "awt_canvas_relayout panel=$instanceId reason=$reason size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} displayable=$isDisplayable" + ) + revalidate() + repaint() + controller?.resize(width, height, reason = reason) + } + } + + fun updateBackground(color: java.awt.Color) { + EventQueue.invokeLater { + if (background != color) { + background = color + repaint() + } + } + } + + fun configure( + bridgeHandlersByMethod: Map, + networkAccessEnabled: Boolean, + onLinkIntercepted: (DesktopEpubLinkClick) -> Unit, + onLoadStateChanged: (Boolean, Float) -> Unit, + onError: (String) -> Unit + ) { + updateHostWindowListener() + this.bridgeHandlersByMethod = bridgeHandlersByMethod + this.networkAccessEnabled = networkAccessEnabled + this.onLinkIntercepted = onLinkIntercepted + this.onLoadStateChanged = { isLoaded, progress -> + if (isLoaded) { + val startedAt = lastLoadStartedAtNanos + if (startedAt > 0L) { + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_loaded panel=$instanceId " + + "durationMs=${startedAt.elapsedOpenTraceMs()} progress=$progress" + } + lastLoadStartedAtNanos = 0L + } + } + onLoadStateChanged(isLoaded, progress) + } + this.onError = { message -> + val startedAt = lastLoadStartedAtNanos + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_error panel=$instanceId " + + "durationMs=${if (startedAt > 0L) startedAt.elapsedOpenTraceMs() else -1L} " + + "message=\"${message.logPreview(240)}\"" + } + lastLoadStartedAtNanos = 0L + onError(message) + } + logDesktopWebView2( + "panel_configure panel=$instanceId handlers=${bridgeHandlersByMethod.size} network=$networkAccessEnabled " + + "controller=${controller != null}" + ) + updateModeSwitchPanelState("configure") + } + + fun loadHtml(html: String) { + if (requestedHtml == html) { + logDesktopWebView2("panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}") + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}" + } + return + } + lastLoadStartedAtNanos = System.nanoTime() + requestedHtml = html + logDesktopWebView2( + "panel_load_requested panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "controller=${controller != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_requested panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} controller=${controller != null} canvas=${width}x${height}" + } + logWebViewLayoutDiag( + "panel_load_requested panel=$instanceId canvas=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} controller=${controller != null}" + ) + updateModeSwitchPanelState("load_requested") + ensureController(reason = "load_requested") + controller?.loadHtml(html) + } + + fun executeJavaScript(script: String) { + logDesktopWebView2( + "panel_execute panel=$instanceId scriptChars=${script.length} scriptHash=${script.hashCode()} controller=${controller != null}" + ) + controller?.executeJavaScript(script) + } + + fun disposeWebView( + waitForSwtDisposal: Boolean = false, + detachAwtCanvas: Boolean = true + ) { + if (disposeInProgress) { + logDesktopWebView2( + "panel_dispose_skip panel=$instanceId reason=in_progress controller=${controller != null}" + ) + updateModeSwitchPanelState("dispose_skip_in_progress") + return + } + disposeInProgress = true + logDesktopWebView2( + "panel_dispose panel=$instanceId controller=${controller != null} " + + "waitForSwtDisposal=$waitForSwtDisposal detachAwtCanvas=$detachAwtCanvas" + ) + try { + updateModeSwitchPanelState("dispose_begin") + if (detachAwtCanvas) { + retireAwtCanvasFromReaderSurface() + } + controller?.dispose(waitForCompletion = waitForSwtDisposal) + controller = null + updateModeSwitchPanelState("dispose_end") + } finally { + disposeInProgress = false + } + } + + override fun addNotify() { + super.addNotify() + updateHostWindowListener() + updateModeSwitchPanelState("add_notify") + logDesktopWebView2( + "panel_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "canvas=${width}x${height} controller=${controller != null} hasHtml=${requestedHtml != null}" + ) + logWebViewLayoutDiag( + "awt_canvas_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "size=${width}x${height} bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} " + + "hasHtml=${requestedHtml != null}" + ) + ensureController(reason = "add_notify") + requestedHtml?.let { html -> controller?.loadHtml(html) } + controller?.resize(width, height, reason = "add_notify") + } + + override fun removeNotify() { + logDesktopWebView2("panel_remove_notify panel=$instanceId") + updateModeSwitchPanelState("remove_notify_begin") + updateHostWindowListener() + disposeWebView( + waitForSwtDisposal = true, + detachAwtCanvas = shouldRetireAwtCanvasFromReaderSurface() + ) + clearHostWindowListener() + super.removeNotify() + updateModeSwitchPanelState("remove_notify_end") + } + + private fun updateHostWindowListener() { + val window = SwingUtilities.getWindowAncestor(this) + if (hostWindow === window) return + clearHostWindowListener() + hostWindow = window + hostWindowClosing = false + if (window == null) return + val listener = object : WindowAdapter() { + override fun windowClosing(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closing panel=$instanceId") + updateModeSwitchPanelState("host_window_closing") + } + + override fun windowClosed(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closed panel=$instanceId") + updateModeSwitchPanelState("host_window_closed") + } + } + hostWindowListener = listener + window.addWindowListener(listener) + } + + private fun clearHostWindowListener() { + hostWindowListener?.let { listener -> + hostWindow?.removeWindowListener(listener) + } + hostWindowListener = null + hostWindow = null + } + + private fun shouldRetireAwtCanvasFromReaderSurface(): Boolean { + val window = hostWindow ?: SwingUtilities.getWindowAncestor(this) + val shouldRetire = desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = hostWindowClosing, + hostWindowDisplayable = window?.isDisplayable == true + ) + if (!shouldRetire) { + logDesktopWebView2( + "panel_retire_skip panel=$instanceId reason=host_window_closing_or_disposed " + + "hostClosing=$hostWindowClosing host=${window.formatAwtComponentState()}" + ) + updateModeSwitchPanelState("retire_skip_host_window_closing_or_disposed") + } + return shouldRetire + } + + private fun retireAwtCanvasFromReaderSurface() { + if (!shouldRetireAwtCanvasFromReaderSurface()) return + runOnAwtEventThreadBlocking( + onError = { error -> + logDesktopWebView2( + "panel_retire_failed panel=$instanceId error=\"${error.message.orEmpty().logPreview(300)}\"" + ) + } + ) { + logWebViewLayoutDiag( + "awt_canvas_retire panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} displayable=$isDisplayable visible=$isVisible" + ) + updateModeSwitchPanelState("retire_begin") + val parentContainer = parent + val grandParent = parentContainer?.parent + logReaderModeSwitch( + "webview2_interop_retire_begin panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + isVisible = false + setBounds(0, 0, 0, 0) + parentContainer?.isVisible = false + parentContainer?.setBounds(0, 0, 0, 0) + parentContainer?.revalidate() + parentContainer?.repaint() + grandParent?.revalidate() + grandParent?.repaint() + repaint() + scheduleRetiredInteropHostCleanup(parentContainer, grandParent, reason = "retire") + updateModeSwitchPanelState("retire_end") + logReaderModeSwitch( + "webview2_interop_retire_end panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + } + } + + private fun scheduleRetiredInteropHostCleanup( + parentContainer: java.awt.Container?, + grandParent: java.awt.Container?, + reason: String + ) { + if (parentContainer == null || grandParent == null) return + EventQueue.invokeLater { + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_next_event") + } + EventQueue.invokeLater { + DesktopWebView2InteropHostCleanupDelaysMillis.forEach { delayMillis -> + javax.swing.Timer(delayMillis.toInt()) { _ -> + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_after_${delayMillis}ms") + }.apply { + isRepeats = false + start() + } + } + } + } + + private fun cleanupRetiredInteropHost( + parentContainer: java.awt.Container, + grandParent: java.awt.Container, + reason: String + ) { + val isInteropHost = parentContainer.javaClass.simpleName == DesktopSwingInteropHostClassName + val ownsOnlyRetiredPanel = parentContainer.components.all { component -> + component === this || !component.isDisplayable || !component.isShowing + } + if (!isInteropHost || !ownsOnlyRetiredPanel || parentContainer.parent !== grandParent) { + logReaderModeSwitch( + "webview2_interop_host_cleanup_skip panel=$instanceId reason=$reason " + + "isInteropHost=$isInteropHost ownsOnlyRetiredPanel=$ownsOnlyRetiredPanel " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + return + } + logReaderModeSwitch( + "webview2_interop_host_cleanup_begin panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + if (parent === parentContainer) { + parentContainer.remove(this) + } + parentContainer.removeAll() + grandParent.remove(parentContainer) + parentContainer.invalidate() + grandParent.invalidate() + grandParent.validate() + grandParent.repaint() + updateModeSwitchPanelState("interop_host_cleanup_$reason") + logReaderModeSwitch( + "webview2_interop_host_cleanup_end panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "grandParentChildren=${grandParent.componentCount}" + ) + } + + private fun updateModeSwitchPanelState(event: String) { + val snapshot = modeSwitchPanelSnapshot(event) + DesktopWebView2ModeSwitchPanelStates[instanceId] = snapshot + logReaderModeSwitch("webview2_panel $snapshot") + } + + fun modeSwitchPanelSnapshot(event: String): String { + val parentContainer = parent + val parentName = parentContainer?.javaClass?.simpleName ?: "none" + val parentDetails = parentContainer.formatAwtComponentState() + return "panel=$instanceId event=$event visible=$isVisible displayable=$isDisplayable " + + "showing=$isShowing size=${width}x${height} bounds=${bounds.formatAwtBounds()} " + + "parent=$parentName parentState=$parentDetails controller=${controller != null} hasHtml=${requestedHtml != null}" + } + + private fun ensureController(reason: String) { + if (controller != null) return + if (!isDisplayable) { + logDesktopWebView2("panel_controller_skip panel=$instanceId reason=$reason displayable=false") + return + } + logDesktopWebView2( + "panel_controller_create panel=$instanceId reason=$reason backend=${backend.logName} hasHtml=${requestedHtml != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create panel=$instanceId reason=$reason " + + "backend=${backend.logName} hasHtml=${requestedHtml != null} canvas=${width}x${height}" + } + var createdController: DesktopWindowsWebView2Controller? = null + val newController = DesktopWindowsWebView2Controller( + instanceId = instanceId, + backend = backend, + canvas = this, + isNetworkAccessEnabled = { networkAccessEnabled }, + dispatchBridgeMessage = { method, params -> + EventQueue.invokeLater { + bridgeHandlersByMethod[method]?.onMessage(params) + } + }, + dispatchLinkClick = { link -> + EventQueue.invokeLater { + onLinkIntercepted(link) + } + }, + updateLoadState = { isLoaded, progress -> + EventQueue.invokeLater { + onLoadStateChanged(isLoaded, progress) + } + }, + reportError = { error -> + val message = error.desktopNativeWebViewMessage(backend) + EventQueue.invokeLater { + createdController?.let { failedController -> + if (controller === failedController) { + controller = null + } + } + onError(message) + } + } + ) + createdController = newController + controller = newController + } +} + +private class DesktopWindowsWebView2Controller( + private val instanceId: Int, + private val backend: DesktopEpubWebViewBackend, + private val canvas: Canvas, + private val isNetworkAccessEnabled: () -> Boolean, + private val dispatchBridgeMessage: (String, String) -> Unit, + private val dispatchLinkClick: (DesktopEpubLinkClick) -> Unit, + private val updateLoadState: (Boolean, Float) -> Unit, + private val reportError: (Throwable) -> Unit +) { + @Volatile + private var disposed = false + + private var shell: Shell? = null + private var browser: Browser? = null + private var bridgeFunction: BrowserFunction? = null + + @Volatile + private var pendingHtml: String? = null + + @Volatile + private var lastBrowserBoundsLog: String = "" + + init { + logDesktopWebView2("controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_init panel=$instanceId backend=${backend.logName} " + + "canvas=${canvas.width}x${canvas.height}" + } + logWebViewLayoutDiag( + "controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} screen=${canvas.safeScreenLocationLog()}" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { display -> + if (!disposed) createBrowser(display) + } + } + + fun loadHtml(html: String) { + logDesktopWebView2( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} browser=${browser != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_enqueue panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} browser=${browser != null}" + } + logWebViewLayoutDiag( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} browser=${browser != null} " + + "canvas=${canvas.width}x${canvas.height} browserBounds=$lastBrowserBoundsLog" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + updateLoadState(false, -1f) + pendingHtml = html + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2("controller_load_pending panel=$instanceId reason=browser_not_ready") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_pending panel=$instanceId reason=browser_not_ready" + } + } else { + pendingHtml = null + setBrowserText(webView, html, reason = "load") + } + } + } + + fun executeJavaScript(script: String) { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2( + "controller_execute_drop panel=$instanceId reason=browser_not_ready " + + "scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } else { + val executed = webView.execute(script) + logDesktopWebView2( + "controller_execute panel=$instanceId executed=$executed scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } + } + } + + fun resize(width: Int, height: Int, reason: String = "resize") { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + applyCanvasSizeToBrowser(width, height, reason = reason) + } + } + + fun dispose(waitForCompletion: Boolean = false) { + if (disposed) return + disposed = true + logDesktopWebView2("controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + logReaderModeSwitch("webview2_controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + if (waitForCompletion) { + DesktopSwtWebView2EventLoop.syncExec({}) { + disposeSwtWidgets() + } + } else { + DesktopSwtWebView2EventLoop.asyncExec({}) { + disposeSwtWidgets() + } + } + } + + private fun disposeSwtWidgets() { + logReaderModeSwitch( + "webview2_swt_dispose_begin panel=$instanceId shell=${shell?.isDisposed == false} browser=${browser?.isDisposed == false}" + ) + bridgeFunction?.takeUnless { it.isDisposed }?.dispose() + bridgeFunction = null + browser?.takeUnless { it.isDisposed }?.dispose() + browser = null + shell?.takeUnless { it.isDisposed }?.dispose() + shell = null + lastBrowserBoundsLog = "" + logReaderModeSwitch("webview2_swt_dispose_end panel=$instanceId") + } + + private fun createBrowser(display: Display) { + logDesktopWebView2("controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}" + } + runCatching { + shell = SWT_AWT.new_Shell(display, canvas) + logDesktopWebView2("controller_shell_created panel=$instanceId shellDisposed=${shell?.isDisposed == true}") + logWebViewLayoutDiag( + "swt_shell_created panel=$instanceId canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + val webView = Browser(shell, backend.swtBrowserStyle()) + browser = webView + val swtBackground = org.eclipse.swt.graphics.Color( + display, + canvas.background.red, + canvas.background.green, + canvas.background.blue + ) + shell?.background = swtBackground + webView.background = swtBackground + shell?.addDisposeListener { + if (!swtBackground.isDisposed) swtBackground.dispose() + } + val browserType = webView.browserType.orEmpty() + logDesktopWebView2( + "controller_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_browser_created panel=$instanceId backend=${backend.logName} " + + "browserType=\"${browserType.logPreview(120)}\"" + } + logWebViewLayoutDiag( + "swt_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\" " + + "browserBounds=${webView.bounds.formatSwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + check(backend.acceptsBrowserType(browserType)) { + "${backend.displayName} is not available; SWT opened '${browserType.ifBlank { "unknown" }}' instead." + } + val warmupHtml = desktopWebView2WarmupHtml(canvas.background) + val warmupAccepted = webView.setText(warmupHtml) + logDesktopReaderOpenTrace { + "event=desktop_webview_warmup_loaded panel=$instanceId accepted=$warmupAccepted " + + "background=\"${canvas.background.toCssHex()}\"" + } + run { + bridgeFunction = object : BrowserFunction(webView, DesktopWebView2NativeBridgeName) { + override fun function(arguments: Array): Any? { + val method = arguments.getOrNull(0)?.toString().orEmpty() + if (method.isBlank()) return null + val params = arguments.getOrNull(1)?.toString() ?: "{}" + if (method == DesktopWebView2DiagnosticMethodName) { + val preview = params.logPreview(6000) + logDesktopWebView2("bridge_diagnostic panel=$instanceId params=\"$preview\"") + logWebViewLayoutDiag("document_probe panel=$instanceId params=\"$preview\"") + } else { + logDesktopWebView2( + "bridge_message panel=$instanceId method=$method paramsChars=${params.length} params=\"${params.logPreview()}\"" + ) + dispatchBridgeMessage(method, params) + } + return null + } + } + webView.addLocationListener( + object : LocationListener { + override fun changing(event: LocationEvent) { + val location = event.location.orEmpty() + logDesktopWebView2( + "location_changing panel=$instanceId top=${event.top} doit=${event.doit} " + + "location=\"${location.logPreview()}\"" + ) + if (!isNetworkAccessEnabled() && location.isRemoteNetworkUrl()) { + logEpubLink("request_blocked_offline url=\"${location.logPreview()}\"") + event.doit = false + return + } + val link = location.readerLinkClickFromIntercept() ?: return + logEpubLink( + "request_intercept_webview2 url=\"${location.logPreview()}\" " + + "href=\"${link.href.logPreview()}\"" + ) + event.doit = false + dispatchLinkClick(link.copy(source = "request")) + } + + override fun changed(event: LocationEvent) = Unit + } + ) + webView.addProgressListener( + object : ProgressAdapter() { + private var lastLoggedProgressBucket = -1 + + override fun changed(event: ProgressEvent) { + val total = event.total + val progress = if (total > 0) { + event.current.coerceIn(0, total).toFloat() / total.toFloat() + } else { + -1f + } + val bucket = if (progress < 0f) { + -1 + } else { + (progress * 4).toInt().coerceIn(0, 4) + } + if (bucket != lastLoggedProgressBucket) { + lastLoggedProgressBucket = bucket + logDesktopWebView2( + "progress_changed panel=$instanceId current=${event.current} total=${event.total} " + + "progress=${if (progress < 0f) "unknown" else progress.formatLogFloat()}" + ) + } + updateLoadState(false, progress) + } + + override fun completed(event: ProgressEvent) { + val bridgeInjected = webView.execute(DesktopWebView2BridgeRuntimeScript) + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "load_completed") + val probeInjected = webView.execute(desktopWebView2DocumentProbeScript("load_completed")) + logDesktopWebView2( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_progress_completed panel=$instanceId " + + "bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + } + logWebViewLayoutDiag( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected " + + "current=${event.current} total=${event.total}" + ) + updateLoadState(true, 1f) + } + } + ) + pendingHtml?.let { html -> + pendingHtml = null + setBrowserText(webView, html, reason = "browser_ready") + } + } + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "open") + shell?.open() + logDesktopWebView2( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + } + logWebViewLayoutDiag( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "hostScale=${canvas.webView2HostScale().scaleX.formatLogFloat()}x${canvas.webView2HostScale().scaleY.formatLogFloat()} " + + "shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()} " + + "browserBounds=${browser?.bounds?.formatSwtBounds().orEmpty()} canvasBounds=${canvas.bounds.formatAwtBounds()}" + ) + }.onFailure { error -> + logDesktopWebView2( + "controller_create_failed panel=$instanceId error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_failed panel=$instanceId " + + "error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + } + reportError(error) + dispose() + } + } + + private fun setBrowserText(webView: Browser, html: String, reason: String) { + val accepted = webView.setText(html) + logDesktopWebView2( + "controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + } + } + + private fun applyCanvasSizeToBrowser(width: Int, height: Int, reason: String) { + val webShell = shell ?: return + val webBrowser = browser + if (webShell.isDisposed || webBrowser?.isDisposed == true) return + val hostScale = canvas.webView2HostScale() + if (width <= 0 || height <= 0) { + logWebViewLayoutDiag( + "controller_resize_skip panel=$instanceId reason=$reason requested=${width}x${height} " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + return + } + val targetBounds = desktopWebView2TargetBoundsForCanvas(width, height) ?: return + webShell.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + webBrowser?.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + lastBrowserBoundsLog = webBrowser?.bounds?.formatSwtBounds().orEmpty() + logDesktopWebView2( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "shellBounds=${webShell.bounds.x},${webShell.bounds.y} ${webShell.bounds.width}x${webShell.bounds.height} " + + "browserBounds=${webBrowser?.bounds?.width ?: -1}x${webBrowser?.bounds?.height ?: -1}" + ) + logWebViewLayoutDiag( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} canvasBounds=${canvas.bounds.formatAwtBounds()} " + + "shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + } +} + +private object DesktopSwtWebView2EventLoop { + private val ready = CountDownLatch(1) + + @Volatile + private var display: Display? = null + + @Volatile + private var startupError: Throwable? = null + + init { + Thread( + { + runCatching { + logDesktopWebView2("swt_event_loop_start") + runCatching { Display.setAppName(EpistemeDesktopWindowTitle) } + if (desktopEpubWebViewUsesWebView2() && + System.getProperty(DesktopWebView2EdgeDataDirProperty).isNullOrBlank() + ) { + System.setProperty( + DesktopWebView2EdgeDataDirProperty, + File(desktopUserCacheRoot(), "webview2").absolutePath + ) + } + if (desktopEpubWebViewUsesWebView2()) { + logDesktopWebView2( + "swt_event_loop_user_data_dir path=\"${System.getProperty(DesktopWebView2EdgeDataDirProperty).orEmpty().logPreview(200)}\"" + ) + } + val swtDisplay = Display() + display = swtDisplay + ready.countDown() + logDesktopWebView2("swt_event_loop_ready") + while (!swtDisplay.isDisposed) { + if (!swtDisplay.readAndDispatch()) { + swtDisplay.sleep() + } + } + }.onFailure { error -> + startupError = error + ready.countDown() + logDesktopWebView2("swt_event_loop_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + } + }, + "Episteme SWT Browser" + ).apply { + isDaemon = true + start() + } + } + + fun asyncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_async_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_async_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_async_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.asyncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_async_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } + + fun syncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_sync_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_sync_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_sync_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.syncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_sync_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } +} + +private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) + +private fun desktopWebView2WarmupHtml(background: java.awt.Color): String { + val cssColor = background.toCssHex() + return """ + + + + + + + + + """.trimIndent() +} + +private fun java.awt.Color.toCssHex(): String { + return "#${red.toTwoDigitHex()}${green.toTwoDigitHex()}${blue.toTwoDigitHex()}" +} + +private fun Int.toTwoDigitHex(): String { + return coerceIn(0, 255).toString(16).padStart(2, '0') +} + +private fun runOnAwtEventThreadBlocking( + onError: (Throwable) -> Unit = {}, + block: () -> Unit +) { + if (EventQueue.isDispatchThread()) { + runCatching(block).onFailure(onError) + return + } + runCatching { + EventQueue.invokeAndWait { + runCatching(block).onFailure(onError) + } + }.onFailure(onError) +} + +private fun java.awt.Rectangle.formatAwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun java.awt.Component?.formatAwtComponentState(): String { + if (this == null) return "none" + return "${javaClass.simpleName}{visible=$isVisible displayable=$isDisplayable showing=$isShowing " + + "size=${width}x$height bounds=${bounds.formatAwtBounds()}}" +} + +private fun java.awt.Container.formatAwtChildrenState(): String { + if (componentCount == 0) return "none" + return components.joinToString(prefix = "[", postfix = "]") { component -> + component.formatAwtComponentState() + } +} + +private fun java.awt.Component.desktopWebView2Descendants(includeSelf: Boolean = false): List { + val descendants = mutableListOf() + if (includeSelf) descendants += this + fun collect(component: java.awt.Component) { + if (component is java.awt.Container) { + component.components.forEach { child -> + descendants += child + collect(child) + } + } + } + collect(this) + return descendants +} + +private fun org.eclipse.swt.graphics.Rectangle.formatSwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun Canvas.safeScreenLocationLog(): String { + return runCatching { + val point = locationOnScreen + "${point.x},${point.y}" + }.getOrDefault("unavailable") +} + +private data class DesktopWebView2HostScale( + val scaleX: Float, + val scaleY: Float +) + +internal data class DesktopWebView2TargetBounds( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +internal fun desktopWebView2TargetBoundsForCanvas(width: Int, height: Int): DesktopWebView2TargetBounds? { + if (width <= 0 || height <= 0) return null + return DesktopWebView2TargetBounds( + x = 0, + y = 0, + width = width.coerceAtLeast(1), + height = height.coerceAtLeast(1) + ) +} + +internal fun desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing: Boolean, + hostWindowDisplayable: Boolean +): Boolean { + return !hostWindowClosing && hostWindowDisplayable +} + +private fun java.awt.Component.webView2HostScale(): DesktopWebView2HostScale { + val transform = graphicsConfiguration?.defaultTransform + return DesktopWebView2HostScale( + scaleX = transform?.scaleX?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f, + scaleY = transform?.scaleY?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f + ) +} + +private fun String.withDesktopWebView2Bootstrap(networkAccessEnabled: Boolean): String { + val injection = buildString { + if (!networkAccessEnabled) { + append(DesktopWebView2OfflineCspMetaTag) + append('\n') + } + append(DesktopWebView2ReaderSurfaceCssTag) + append('\n') + append(DesktopWebView2BridgeScriptTag) + } + val headStart = Regex("]*>", RegexOption.IGNORE_CASE).find(this) + if (headStart != null) { + val insertAt = headStart.range.last + 1 + return substring(0, insertAt) + "\n" + injection + "\n" + substring(insertAt) + } + return "$injection\n$this" +} + +internal fun desktopNativeWebViewUnavailableMessage( + backend: DesktopEpubWebViewBackend, + detail: String? = null +): String { + val base = when (backend) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> + "Microsoft Edge WebView2 runtime is unavailable. Install or repair the WebView2 Runtime." + DesktopEpubWebViewBackend.WEBKIT -> + "WebKitGTK is unavailable. Install WebKitGTK from your Linux distribution packages." + DesktopEpubWebViewBackend.UNSUPPORTED -> + "Native webview is unavailable on this desktop platform." + } + val trimmedDetail = detail?.trim().orEmpty() + return if (trimmedDetail.isBlank()) base else "$base $trimmedDetail" +} + +private fun Throwable.desktopNativeWebViewMessage(backend: DesktopEpubWebViewBackend): String { + return desktopNativeWebViewUnavailableMessage( + backend = backend, + detail = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName + ) +} + +private fun desktopWebView2DocumentProbeScript(eventName: String): String { + return """ + (function () { + try { + var body = document.body; + var root = document.documentElement; + var firstChapter = document.querySelector('.chapter'); + var firstContent = document.querySelector('.reader-content'); + var blockSelector = 'p, div, h1, h2, h3, h4, h5, h6, li, blockquote, figure, table, pre'; + function round(value) { + return Math.round(Number(value || 0)); + } + function cssValue(element, name) { + if (!element) return ''; + var style = window.getComputedStyle(element); + return style ? (style.getPropertyValue(name) || '') : ''; + } + function cssVar(name) { + return cssValue(root, name).trim(); + } + function rectPayload(element) { + if (!element) return null; + var rect = element.getBoundingClientRect(); + var centerX = rect.left + (rect.width / 2); + var centerY = rect.top + (rect.height / 2); + var viewportHeight = window.innerHeight || 0; + return { + left: round(rect.left), + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + width: round(rect.width), + height: round(rect.height), + centerX: round(centerX), + centerDelta: round(centerX - ((window.innerWidth || 0) / 2)), + centerY: round(centerY), + viewportHeightDelta: round(rect.height - viewportHeight), + marginLeft: cssValue(element, 'margin-left').trim(), + marginRight: cssValue(element, 'margin-right').trim(), + paddingLeft: cssValue(element, 'padding-left').trim(), + paddingRight: cssValue(element, 'padding-right').trim(), + paddingTop: cssValue(element, 'padding-top').trim(), + paddingBottom: cssValue(element, 'padding-bottom').trim(), + textAlign: cssValue(element, 'text-align').trim(), + display: cssValue(element, 'display').trim(), + cssFloat: cssValue(element, 'float').trim(), + clear: cssValue(element, 'clear').trim(), + cssWidth: cssValue(element, 'width').trim(), + maxWidth: cssValue(element, 'max-width').trim(), + minHeight: cssValue(element, 'min-height').trim(), + boxSizing: cssValue(element, 'box-sizing').trim() + }; + } + function visibleChapter() { + var chapters = Array.prototype.slice.call(document.querySelectorAll('[data-reader-chapter-index]')); + var viewportTop = 0; + var viewportBottom = window.innerHeight || 0; + var best = null; + var bestVisibleHeight = -1; + chapters.forEach(function (candidate) { + var rect = candidate.getBoundingClientRect(); + var visibleHeight = Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop); + if (visibleHeight > bestVisibleHeight && rect.bottom >= viewportTop && rect.top <= viewportBottom) { + best = candidate; + bestVisibleHeight = visibleHeight; + } + }); + return best || firstChapter; + } + function visibleBlockIn(content) { + if (!content) return null; + var blocks = Array.prototype.slice.call(content.querySelectorAll(blockSelector)); + for (var i = 0; i < blocks.length; i++) { + var rect = blocks[i].getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= (window.innerHeight || 0)) { + return blocks[i]; + } + } + return blocks[0] || null; + } + var chapter = visibleChapter(); + var content = chapter ? (chapter.querySelector('.reader-content') || chapter) : firstContent; + var firstBlock = firstContent ? firstContent.querySelector(blockSelector) : null; + var visibleBlock = visibleBlockIn(content); + var viewportCenterX = Math.max(0, Math.min((window.innerWidth || 0) - 1, Math.round((window.innerWidth || 0) / 2))); + var viewportTopY = Math.max(0, Math.min((window.innerHeight || 0) - 1, 8)); + var topElement = document.elementFromPoint(viewportCenterX, viewportTopY); + var topBlock = topElement && topElement.closest ? topElement.closest(blockSelector) : null; + var sampledElement = document.elementFromPoint( + viewportCenterX, + Math.max(0, Math.min((window.innerHeight || 0) - 1, Math.round((window.innerHeight || 0) / 2))) + ); + var sampledBlock = sampledElement && sampledElement.closest ? sampledElement.closest(blockSelector) : null; + var payload = { + event: '$eventName', + readyState: document.readyState || '', + title: document.title || '', + url: location.href || '', + devicePixelRatio: window.devicePixelRatio || 1, + bodyClass: body ? body.className : '', + rootClass: root ? root.className : '', + readerAlign: cssVar('--reader-align'), + readerMarginX: cssVar('--reader-margin-x'), + readerMarginY: cssVar('--reader-margin-y'), + readerVerticalMarginY: cssVar('--reader-vertical-margin-y'), + readerVerticalContentWidth: cssVar('--reader-vertical-content-width'), + readerVerticalPageWidth: cssVar('--reader-vertical-page-width'), + readerFontSize: cssVar('--reader-font-size'), + bodyZoom: cssValue(body, 'zoom').trim(), + bodyChildren: body ? body.children.length : -1, + bodyTextChars: body && body.innerText ? body.innerText.length : 0, + bodyHtmlChars: body && body.innerHTML ? body.innerHTML.length : 0, + bodyClientWidth: body ? body.clientWidth : -1, + bodyScrollWidth: body ? body.scrollWidth : -1, + rootClientWidth: root ? root.clientWidth : -1, + rootScrollWidth: root ? root.scrollWidth : -1, + scrollHeight: root ? root.scrollHeight : -1, + clientHeight: root ? root.clientHeight : -1, + viewportWidth: window.innerWidth || -1, + viewportHeight: window.innerHeight || -1, + visualViewportWidth: window.visualViewport ? round(window.visualViewport.width) : -1, + visualViewportHeight: window.visualViewport ? round(window.visualViewport.height) : -1, + visualViewportScale: window.visualViewport ? window.visualViewport.scale : -1, + scrollX: window.scrollX || 0, + topElementTag: topElement ? topElement.tagName : '', + topElementClass: topElement && topElement.className ? String(topElement.className) : '', + topBlockTag: topBlock ? topBlock.tagName : '', + topBlockRect: rectPayload(topBlock), + bodyRect: rectPayload(body), + rootRect: rectPayload(root), + firstChapterRect: rectPayload(firstChapter), + firstContentRect: rectPayload(firstContent), + visibleChapterIndex: chapter ? chapter.getAttribute('data-reader-chapter-index') : '', + chapterRect: rectPayload(chapter), + contentRect: rectPayload(content), + firstBlockTag: firstBlock ? firstBlock.tagName : '', + firstBlockRect: rectPayload(firstBlock), + visibleBlockTag: visibleBlock ? visibleBlock.tagName : '', + visibleBlockRect: rectPayload(visibleBlock), + sampledBlockTag: sampledBlock ? sampledBlock.tagName : '', + sampledBlockRect: rectPayload(sampledBlock) + }; + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify(payload)); + } + } catch (error) { + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: '$eventName', + error: String(error && error.message ? error.message : error) + })); + } + } + })(); + """.trimIndent() +} + +private var DesktopWebView2InstanceSeed = 0 +private val DesktopWebView2ModeSwitchPanelStates = ConcurrentHashMap() + +@Synchronized +private fun nextDesktopWebView2InstanceId(): Int { + DesktopWebView2InstanceSeed += 1 + return DesktopWebView2InstanceSeed +} + +internal fun logDesktopWebView2ModeSwitchSnapshot(reason: String) { + val states = DesktopWebView2ModeSwitchPanelStates + .toSortedMap() + .values + .joinToString(separator = " | ") + .ifBlank { "none" } + logReaderModeSwitch( + "webview2_snapshot reason=$reason knownPanelCount=${DesktopWebView2ModeSwitchPanelStates.size} panels=$states" + ) +} + +internal fun cleanupRetiredDesktopWebView2InteropHosts(window: java.awt.Window?, reason: String) { + EventQueue.invokeLater { + if (window == null) { + logReaderModeSwitch("webview2_interop_host_sweep_skip reason=$reason window=null") + return@invokeLater + } + val interopHosts = window + .desktopWebView2Descendants() + .filterIsInstance() + .filter { component -> component.javaClass.simpleName == DesktopSwingInteropHostClassName } + if (interopHosts.isEmpty()) { + logReaderModeSwitch( + "webview2_interop_host_sweep reason=$reason window=${window.formatAwtComponentState()} hosts=none" + ) + return@invokeLater + } + interopHosts.forEach { host -> + cleanupRetiredDesktopWebView2InteropHost(window, host, reason) + } + } +} + +private fun cleanupRetiredDesktopWebView2InteropHost( + window: java.awt.Window, + host: java.awt.Container, + reason: String +) { + val panels = host + .desktopWebView2Descendants(includeSelf = true) + .filterIsInstance() + val hostRetired = !host.isShowing || !host.isVisible || host.width <= 0 || host.height <= 0 + val panelsRetired = panels.isNotEmpty() && panels.all { panel -> + !panel.isDisplayable || !panel.isShowing || panel.width <= 0 || panel.height <= 0 || !panel.hasController + } + val parent = host.parent + val removable = parent != null && hostRetired && panelsRetired + val panelStates = panels.joinToString(prefix = "[", postfix = "]") { panel -> + "panel=${panel.instanceId}{visible=${panel.isVisible} displayable=${panel.isDisplayable} " + + "showing=${panel.isShowing} size=${panel.width}x${panel.height} controller=${panel.hasController}}" + }.ifBlank { "none" } + logReaderModeSwitch( + "webview2_interop_host_sweep_candidate reason=$reason removable=$removable " + + "hostRetired=$hostRetired panelsRetired=$panelsRetired " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} panels=$panelStates children=${host.formatAwtChildrenState()}" + ) + if (!removable) return + panels.forEach { panel -> + if (panel.parent === host) { + host.remove(panel) + } + } + host.removeAll() + parent?.remove(host) + host.invalidate() + parent?.invalidate() + parent?.validate() + parent?.repaint() + window.invalidate() + window.validate() + window.repaint() + panels.forEach { panel -> + DesktopWebView2ModeSwitchPanelStates[panel.instanceId] = + panel.modeSwitchPanelSnapshot("interop_host_sweep_removed_$reason") + logReaderModeSwitch("webview2_panel ${DesktopWebView2ModeSwitchPanelStates[panel.instanceId]}") + } + logReaderModeSwitch( + "webview2_interop_host_sweep_removed reason=$reason " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} parentChildren=${parent?.componentCount ?: -1}" + ) +} + +private const val DesktopSwtReadyTimeoutSeconds = 10L +private val DesktopWebView2FullscreenRelayoutDelaysMillis = longArrayOf(180L, 260L, 420L) +private val DesktopWebView2InteropHostCleanupDelaysMillis = longArrayOf(80L, 220L) +private const val DesktopWebView2NativeBridgeName = "epistemeCallNative" +private const val DesktopWebView2DiagnosticMethodName = "readerWebView2Diagnostic" +private const val DesktopSwingInteropHostClassName = "SwingInteropViewGroup" +private const val DesktopWebView2EdgeDataDirProperty = "org.eclipse.swt.browser.EdgeDataDir" + +private fun DesktopEpubWebViewBackend.swtBrowserStyle(): Int { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> SWT.EDGE + DesktopEpubWebViewBackend.WEBKIT -> SWT.WEBKIT + DesktopEpubWebViewBackend.UNSUPPORTED -> SWT.NONE + } +} + +private fun DesktopEpubWebViewBackend.acceptsBrowserType(browserType: String): Boolean { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> browserType.equals("edge", ignoreCase = true) + DesktopEpubWebViewBackend.WEBKIT -> + browserType.contains("webkit", ignoreCase = true) || browserType.equals("safari", ignoreCase = true) + DesktopEpubWebViewBackend.UNSUPPORTED -> false + } +} + +private val DesktopWebView2BridgeRuntimeScript = """ + (function () { + window.kmpJsBridge = window.kmpJsBridge || {}; + window.kmpJsBridge.callNative = function (method, params) { + if (!window.$DesktopWebView2NativeBridgeName) return null; + var payload = '{}'; + if (typeof params === 'string') { + payload = params; + } else { + try { payload = JSON.stringify(params || {}); } catch (error) { payload = '{}'; } + } + return window.$DesktopWebView2NativeBridgeName(String(method || ''), payload); + }; + })(); +""".trimIndent() + +private val DesktopWebView2ReaderSurfaceCssTag = """ + +""".trimIndent() + +private val DesktopWebView2HorizontalClampScript = """ + (function () { + if (window.readerWebView2HorizontalClampInstalled) return; + window.readerWebView2HorizontalClampInstalled = true; + var clampQueued = false; + function clampHorizontalScroll() { + clampQueued = false; + var root = document.documentElement; + var body = document.body; + var changed = false; + if (window.scrollX) { + window.scrollTo({ top: window.scrollY || 0, left: 0, behavior: 'auto' }); + changed = true; + } + if (root && root.scrollLeft) { + root.scrollLeft = 0; + changed = true; + } + if (body && body.scrollLeft) { + body.scrollLeft = 0; + changed = true; + } + if (changed && window.kmpJsBridge && window.kmpJsBridge.callNative) { + try { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: 'horizontal_scroll_clamped' + })); + } catch (error) {} + } + } + function scheduleClamp() { + if (clampQueued) return; + clampQueued = true; + window.requestAnimationFrame(clampHorizontalScroll); + } + window.addEventListener('scroll', scheduleClamp, { passive: true }); + window.addEventListener('resize', scheduleClamp, { passive: true }); + document.addEventListener('DOMContentLoaded', scheduleClamp, { once: true }); + window.addEventListener('load', scheduleClamp, { once: true }); + scheduleClamp(); + })(); +""".trimIndent() + +private val DesktopWebView2BridgeScriptTag = """ + +""".trimIndent() + +private const val DesktopWebView2OfflineCspMetaTag = + "" 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 b8a5f91..c3b94e9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt @@ -3,6 +3,7 @@ package com.aryan.reader.desktop import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -23,7 +24,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPlacement @@ -41,13 +41,13 @@ import com.aryan.reader.shared.LibraryAction 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.ReaderCloudTtsState import com.aryan.reader.shared.ReaderContextExtractor import com.aryan.reader.shared.RecapResult import com.aryan.reader.shared.ReaderExternalLookupAction import com.aryan.reader.shared.ReaderExtrasState import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.ReaderTtsCacheSummary import com.aryan.reader.shared.ReaderTtsChunk @@ -86,29 +86,36 @@ import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.SharedEpubMetadataEditor import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate import com.aryan.reader.shared.reader.SharedEpubPaginationCache +import com.aryan.reader.shared.reader.SharedJvmBookLoadSemanticMode import com.aryan.reader.shared.reader.SharedJvmBookLoader +import com.aryan.reader.shared.readerCloudTtsControlsModel import com.aryan.reader.shared.reduce import com.aryan.reader.shared.sharedSettingsHubModel +import com.aryan.reader.shared.shouldApplyRemoteCloudBookMetadataUpdate +import com.aryan.reader.shared.shouldUploadLocalCloudBookContent +import com.aryan.reader.shared.shouldUploadLocalCloudBookMetadataUpdate import com.aryan.reader.shared.ui.NonReaderLibraryTab import com.aryan.reader.shared.ui.SharedAboutScreen 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.SharedAppThemeControls import com.aryan.reader.shared.ui.SharedAppThemeSettingsDialog import com.aryan.reader.shared.ui.SharedBookInfoDialog import com.aryan.reader.shared.ui.SharedConfirmDialog import com.aryan.reader.shared.ui.SharedCustomFontsScreen import com.aryan.reader.shared.ui.SharedHelpFeedbackScreen import com.aryan.reader.shared.ui.LocalSharedStringResolver +import com.aryan.reader.shared.ui.SharedManageShelfBooksDialog import com.aryan.reader.shared.ui.SharedOpdsScreen import com.aryan.reader.shared.ui.SharedReaderModalOwnerWindowProvider +import com.aryan.reader.shared.ui.SharedReaderTtsOverlayControls import com.aryan.reader.shared.ui.SharedSettingsHub import com.aryan.reader.shared.ui.SharedSupportProjectScreen import com.aryan.reader.shared.ui.SharedTextInputDialog import com.aryan.reader.shared.ui.readerString import com.aryan.reader.shared.withTtsReplacements -import dev.datlag.kcef.KCEF import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -121,7 +128,15 @@ import java.io.File import java.net.URI import java.util.Base64 import java.util.UUID -import kotlin.math.max +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +private const val DesktopReaderCloseDisposeSyncDelayMillis = 350L +private const val DesktopVerticalInitialPreparedHtmlChapterRadius = 2 +private const val DesktopLibraryOpenPersistDebounceMillis = 300L +private const val DesktopCloudContentRetryDelayMillis = 10_000L +private const val DesktopReaderPositionPersistDebounceMillis = 650L +private const val DesktopProgressEpsilon = 0.001f private enum class DesktopFeatureNoticeAction { SIGN_IN, @@ -138,6 +153,11 @@ private data class DesktopFeatureNotice( val action: DesktopFeatureNoticeAction? = null ) +private data class DesktopFeatureNoticeState( + val notice: DesktopFeatureNotice, + val placement: DesktopFeatureNoticePlacement +) + private data class DesktopCloudSyncCredentials( val userId: String, val idToken: String, @@ -171,6 +191,7 @@ internal fun EpistemeDesktopApp( return desktopStringResolver.quantityString(name, quantity, fallbackOne, fallbackOther, *args) } val featurePolicy = desktopBuildProfile.featurePolicy + val desktopAiKeySettingsAvailable = desktopBuildProfile.aiKeySettingsAvailable val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } val readerEngine = remember { ReaderEngine() } val libraryDatabase = remember { DesktopLibraryDatabase() } @@ -192,6 +213,16 @@ internal fun EpistemeDesktopApp( val desktopAccountProfileRepository = remember { DesktopAccountProfileRepository(desktopCloudConfig) } val desktopCloudSyncSettingsStore = remember { DesktopCloudSyncSettingsStore() } val initialDesktopCloudSyncSettings = remember { desktopCloudSyncSettingsStore.load() } + val initialDesktopAccountSession = remember(desktopBuildProfile, featurePolicy) { + if (featurePolicy.aiAndCloud && featurePolicy.networkAccess && !desktopBuildProfile.byokAiAvailable) { + desktopAuthRepository.currentSession() + } else { + null + } + } + val initialDesktopAccountProfile = remember(initialDesktopAccountSession?.user?.uid) { + initialDesktopAccountSession?.user?.uid?.let(desktopAccountProfileRepository::cachedProfile) + } val desktopInstallationIdStore = remember { DesktopInstallationIdStore() } val desktopFirestoreRepository = remember { DesktopFirestoreRepository(desktopCloudConfig) } val desktopGoogleDriveRepository = remember { DesktopGoogleDriveRepository() } @@ -207,10 +238,19 @@ internal fun EpistemeDesktopApp( var aiByokSettings by remember { mutableStateOf(aiByokStore.load()) } + val sanitizedAiByokSettings = aiByokSettings.toDesktopPersistableAiSettings() + val desktopByokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitizedAiByokSettings.isByokCloudTtsAvailable + val desktopCreditCloudTtsControlsAvailable = + desktopBuildProfile.creditBackedCloudTtsControlsAvailable && desktopCloudConfig.isTtsWorkerConfigured + val desktopCloudTtsControlsAvailable = desktopByokCloudTtsAvailable || desktopCreditCloudTtsControlsAvailable + val desktopCloudTtsUsesCredits = desktopCreditCloudTtsControlsAvailable && !desktopByokCloudTtsAvailable val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() } val scope = rememberCoroutineScope() - var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) } - var webViewRuntimeRequested by remember { mutableStateOf(false) } + val webViewRuntimeState = remember { + DesktopWebViewRuntimeState(initialized = desktopEpubWebViewUsesNativeSwtBrowser()) + } var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen @@ -223,16 +263,13 @@ internal fun EpistemeDesktopApp( enabled = readerFullscreen && !appWindowFullscreen ) - DisposableEffect(Unit) { - onDispose { - KCEF.disposeBlocking() - } - } - var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) } var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) } var state by remember { val initialState = initialLibrarySnapshot.toDesktopReaderScreenState().copy( + currentUser = initialDesktopAccountSession?.user, + isProUser = initialDesktopAccountProfile?.isProUser == true, + credits = initialDesktopAccountProfile?.credits ?: 0, isSyncEnabled = initialDesktopCloudSyncSettings.isSyncEnabled, isFolderSyncEnabled = initialDesktopCloudSyncSettings.isFolderSyncEnabled ) @@ -247,20 +284,45 @@ internal fun EpistemeDesktopApp( var accountStatusMessage by remember { mutableStateOf(null) } var accountBusy by remember { mutableStateOf(false) } var accountRefreshRequestCount by remember { mutableStateOf(0) } + var desktopAccountProfileRefreshCompleted by remember { + mutableStateOf( + !featurePolicy.aiAndCloud || + desktopBuildProfile.byokAiAvailable || + initialDesktopAccountSession == null + ) + } + fun requestDesktopAccountRefreshAfterUsage(usage: DesktopPaidAiUsage = DesktopPaidAiUsage()) { + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return + scope.launch { + val nextCredits = desktopCreditsAfterPaidAiUsage(state.credits, usage.cost) + if (nextCredits != state.credits) { + state = libraryProjector.projectDesktopLibraryState( + state = state.copy(credits = nextCredits), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + } + accountRefreshRequestCount++ + } + } fun effectiveAiSettings(): ReaderAiByokSettings { - val hidden = aiByokSettings.hideReaderAiFeatures + val sanitized = aiByokSettings.toDesktopPersistableAiSettings() + val byokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitized.isByokCloudTtsAvailable return if (desktopBuildProfile.byokAiAvailable) { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) } else { ReaderAiByokSettings( - hideReaderAiFeatures = hidden, - ttsSpeakerId = aiByokSettings.sanitized().ttsSpeakerId, + geminiKey = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.geminiKey else "", + hideReaderAiFeatures = false, + ttsModel = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.ttsModel else "", + ttsSpeakerId = sanitized.ttsSpeakerId, serverBackedReaderAiFeatures = featurePolicy.aiAndCloud && featurePolicy.networkAccess, - serverBackedCloudTts = featurePolicy.aiAndCloud && - featurePolicy.networkAccess && + serverBackedCloudTts = !byokCloudTtsAvailable && + desktopCreditCloudTtsControlsAvailable && state.currentUser != null && - state.credits > 0 && - desktopCloudConfig.isTtsWorkerConfigured + state.credits > 0 ) } } @@ -279,10 +341,7 @@ internal fun EpistemeDesktopApp( currentSignedIn = { state.currentUser != null }, currentIsProUser = { state.isProUser }, currentCredits = { state.credits }, - onUsageCompleted = { - scope.launch { accountRefreshRequestCount++ } - Unit - } + onUsageReported = ::requestDesktopAccountRefreshAfterUsage ) } } @@ -292,15 +351,15 @@ internal fun EpistemeDesktopApp( networkAccess = { featurePolicy.networkAccess }, workerUrlProvider = { desktopCloudConfig.ttsWorkerUrl }, authTokenProvider = { desktopAuthRepository.freshIdToken() }, - useWorkerProvider = { !desktopBuildProfile.byokAiAvailable }, + useWorkerProvider = { true }, onWorkerUsageCompleted = { - scope.launch { accountRefreshRequestCount++ } + requestDesktopAccountRefreshAfterUsage() Unit } ) } val desktopSummaryCacheStore = remember { DesktopSummaryCacheStore() } - var selectedTab by remember { mutableStateOf(SharedAppTab.HOME) } + var selectedTab by remember { mutableStateOf(DesktopInitialAppTab) } var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) } var customFonts by remember { mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) @@ -309,75 +368,23 @@ internal fun EpistemeDesktopApp( var reflowingPdfBookIds by remember { mutableStateOf>(emptySet()) } 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()) - } - } - LaunchedEffect(readerWindows) { - if (readerWindows.any { window -> - val content = window.content - content is DesktopReaderWindowContent.Text && - content.session.reader.book.chapters.isNotEmpty() && - content.session.reader.settings.readingMode == ReaderReadingMode.VERTICAL - } - ) { - webViewRuntimeRequested = true - } - } var nextReaderOpenRequestId by remember { mutableStateOf(0L) } var showCreateShelfDialog by remember { mutableStateOf(false) } + var createShelfBookIds by remember { mutableStateOf>(emptySet()) } + var createShelfClearsSelection by remember { mutableStateOf(false) } var showCreateSmartShelfDialog by remember { mutableStateOf(false) } var shelfToRename by remember { mutableStateOf(null) } var shelfToDelete by remember { mutableStateOf(null) } var folderToRemove by remember { mutableStateOf(null) } - var showAddToShelfDialog by remember { mutableStateOf(false) } + var addToShelfBookIds by remember { mutableStateOf>(emptySet()) } + var addToShelfClearsSelection by remember { mutableStateOf(false) } + var shelfToManageBooks by remember { mutableStateOf(null) } var showTagSelectionDialog by remember { mutableStateOf(false) } var showAiByokSettingsDialog by remember { mutableStateOf(false) } var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) } var showDesktopLanguageDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) } - var desktopFeatureNotice by remember { mutableStateOf(null) } + var desktopFeatureNoticeState by remember { mutableStateOf(null) } var settingsQuery by remember { mutableStateOf("") } var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } var bookInfoDialogFor by remember { mutableStateOf(null) } @@ -386,10 +393,38 @@ internal fun EpistemeDesktopApp( var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } var opdsState by remember { mutableStateOf(opdsController.state) } var desktopCloudSyncJob by remember { mutableStateOf(null) } + var desktopCloudContentRetryJob by remember { mutableStateOf(null) } var pendingDesktopCloudSyncAfterActive by remember { mutableStateOf(false) } val desktopBookCloudSyncJobs = remember { mutableMapOf() } + val pendingLibraryPersistJob = remember { AtomicReference(null) } + val desktopBookSidecarSaveJobs = remember { ConcurrentHashMap() } + var readerCloudDirtyBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudDirtyBaseTimestamps by remember { mutableStateOf>(emptyMap()) } + var readerCloudDirtySidecarBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudStalePositionGuards by remember { mutableStateOf>(emptyMap()) } + var closingReaderBookIds by remember { mutableStateOf>(emptySet()) } var initialDesktopCloudSyncDone by remember { mutableStateOf(false) } val readerWindowDefaults = remember(desktopBuildProfile) { epistemeDesktopWindowDefaults(desktopBuildProfile) } + val readerWindowStateStore = remember { + DesktopWindowStateStore(DesktopWindowStateStore.defaultReaderWindowStateFile()) + } + var savedReaderWindowState by remember { + mutableStateOf(readerWindowStateStore.load()?.toPersistableReaderWindowSnapshot()) + } + + fun showDesktopFeatureNotice( + notice: DesktopFeatureNotice, + readerWindowId: String? = null + ) { + desktopFeatureNoticeState = DesktopFeatureNoticeState( + notice = notice, + placement = desktopFeatureNoticePlacement(readerWindowId) + ) + } + + fun dismissDesktopFeatureNotice() { + desktopFeatureNoticeState = null + } fun projectState( next: SharedReaderScreenState, @@ -407,46 +442,126 @@ internal fun EpistemeDesktopApp( projected: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs, - fonts: List = customFonts + fonts: List = customFonts, + persistDebounceMillis: Long = 0L ) { - scope.launch(Dispatchers.IO) { + val snapshot = projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + pendingLibraryPersistJob.getAndSet(null)?.cancel() + val persistJob = scope.launch(Dispatchers.IO) { runCatching { - libraryDatabase.save( - projected.toDesktopLibrarySnapshot( - shelfRecords = records, - shelfRefs = refs, - customFonts = fonts - ) - ) + if (persistDebounceMillis > 0L) { + delay(persistDebounceMillis) + } + libraryDatabase.save(snapshot) } } + pendingLibraryPersistJob.set(persistJob) + persistJob.invokeOnCompletion { + pendingLibraryPersistJob.compareAndSet(persistJob, null) + } } fun replaceLibrary( next: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs, - fonts: List = customFonts + fonts: List = customFonts, + persistDebounceMillis: Long = 0L ) { shelfRecords = records shelfRefs = refs val projected = projectState(next, records, refs) state = projected - persistSnapshot(projected, records, refs, fonts) + persistSnapshot(projected, records, refs, fonts, persistDebounceMillis) } - fun updateState(next: SharedReaderScreenState) { + fun updateState(next: SharedReaderScreenState, persistDebounceMillis: Long = 0L) { val projected = projectState(next) state = projected - persistSnapshot(projected) + persistSnapshot(projected, persistDebounceMillis = persistDebounceMillis) + } + + fun flushDesktopPersistenceBeforeDispose( + projected: SharedReaderScreenState, + records: List, + refs: List, + fonts: List + ) { + pendingLibraryPersistJob.getAndSet(null)?.cancel() + runCatching { + libraryDatabase.save( + projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + ) + } + + val pendingSidecarBookIds = desktopBookSidecarSaveJobs.keys.toList() + if (pendingSidecarBookIds.isEmpty()) return + + desktopBookSidecarSaveJobs.values.forEach { it.cancel() } + desktopBookSidecarSaveJobs.clear() + val booksById = projected.rawLibraryBooks.associateBy { it.id } + pendingSidecarBookIds + .mapNotNull(booksById::get) + .filter { book -> + val sourceFolder = book.sourceFolder ?: return@filter false + projected.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + .forEach { book -> + runCatching { DesktopLocalFolderSync.saveBookSidecars(book) } + } + } + + fun DesktopReaderWindowState.cancelReaderWork() { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired, + is DesktopReaderWindowContent.Pdf -> Unit + is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() + } + } + + fun DesktopReaderWindowState.readerCloseContentLabel(): String { + return when (content) { + DesktopReaderWindowContent.Opening -> "opening" + is DesktopReaderWindowContent.PasswordRequired -> "password_required" + is DesktopReaderWindowContent.Pdf -> "pdf" + is DesktopReaderWindowContent.Text -> "text" + } } fun DesktopReaderWindowState.closeReaderResources() { - when (val content = content) { - DesktopReaderWindowContent.Opening, - is DesktopReaderWindowContent.PasswordRequired -> Unit - is DesktopReaderWindowContent.Pdf -> content.document.close() - is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() + logDesktopReaderClose( + "close_resources_begin windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} fullscreen=$fullscreen" + ) + cancelReaderWork() + runCatching { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired -> Unit + is DesktopReaderWindowContent.Pdf -> content.document.close() + is DesktopReaderWindowContent.Text -> Unit + } + }.onSuccess { + logDesktopReaderClose( + "close_resources_end windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()}" + ) + }.onFailure { error -> + logDesktopReaderClose( + "close_resources_fail windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} error=\"${error.message.orEmpty().logPreview(240)}\" " + + "type=${error.javaClass.simpleName}" + ) + throw error } } @@ -470,46 +585,46 @@ internal fun EpistemeDesktopApp( return readerWindows.firstOrNull { it.id == windowId }?.content as? DesktopReaderWindowContent.Text } - fun closeReaderWindow(windowId: String) { - val closing = readerWindows.firstOrNull { it.id == windowId } ?: return - val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - closing.closeReaderResources() - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } + fun saveReaderWindowStateSnapshot(snapshot: DesktopWindowStateSnapshot?) { + val persistable = snapshot?.toPersistableReaderWindowSnapshot() ?: return + savedReaderWindowState = persistable + scope.launch(Dispatchers.IO) { + runCatching { readerWindowStateStore.save(persistable) } } - readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) - updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) } - fun closeReaderWindowsForBookIds(bookIds: Set) { + fun markReaderCloudDirty( + bookId: String, + baseTimestamp: Long? = null, + sidecarsDirty: Boolean = false + ) { + if (bookId.isBlank()) return + if (bookId !in readerCloudDirtyBookIds) { + val resolvedBaseTimestamp = baseTimestamp + ?: state.rawLibraryBooks.firstOrNull { it.id == bookId }?.timestamp + ?: 0L + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps + (bookId to resolvedBaseTimestamp) + logDesktopCloudSync { + "desktop.reader.dirty_start book=$bookId baseTs=$resolvedBaseTimestamp sidecarsDirty=$sidecarsDirty" + } + } + if (sidecarsDirty) { + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds + bookId + } + readerCloudDirtyBookIds = readerCloudDirtyBookIds + bookId + } + + fun clearReaderCloudDirty(bookIds: Set) { if (bookIds.isEmpty()) return - val closing = readerWindows.filter { it.bookId in bookIds } - val shouldStopTts = closing.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - closing.forEach { it.closeReaderResources() } - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) + readerCloudDirtyBookIds = readerCloudDirtyBookIds - bookIds + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps - bookIds + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds - bookIds } - fun closeAllReaderWindows() { - val shouldStopTts = readerWindows.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - readerWindows.forEach { it.closeReaderResources() } - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - readerWindows = emptyList() - updateState(state.reduce(AppAction.AllTabsClosed)) + fun markReaderBooksClosing(bookIds: Set) { + if (bookIds.isEmpty()) return + closingReaderBookIds = closingReaderBookIds + bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookIds } fun downloadReaderImage(image: ReaderImageReference) { @@ -542,6 +657,12 @@ internal fun EpistemeDesktopApp( desktopCloudConfig.isAuthConfigured } + fun desktopAccountAvailable(): Boolean { + return featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + !desktopBuildProfile.byokAiAvailable + } + fun saveDesktopCloudSyncSettings( syncEnabled: Boolean = state.isSyncEnabled, folderSyncEnabled: Boolean = state.isFolderSyncEnabled @@ -555,44 +676,55 @@ internal fun EpistemeDesktopApp( } suspend fun refreshDesktopAccountProfile(showBanner: Boolean = false) { - if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) { + desktopAccountProfileRefreshCompleted = true + return + } val session = desktopAuthRepository.restoreSavedSession() if (session == null) { if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true return } val token = desktopAuthRepository.freshIdToken() if (token.isNullOrBlank()) { if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true return } runCatching { desktopAccountProfileRepository.fetchProfile(session.user.uid, token) }.onSuccess { profile -> - val nextSyncEnabled = state.isSyncEnabled && profile.isProUser - if (!nextSyncEnabled && state.isSyncEnabled) { - saveDesktopCloudSyncSettings(syncEnabled = false) - } - updateState( - state.copy( - currentUser = session.user, - isProUser = profile.isProUser, - credits = profile.credits, - isSyncEnabled = nextSyncEnabled + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + desktopAccountProfileRepository.saveFetchedProfile(session.user.uid, profile) + val nextSyncEnabled = state.isSyncEnabled && profile.isProUser + if (!nextSyncEnabled && state.isSyncEnabled) { + saveDesktopCloudSyncSettings(syncEnabled = false) + } + updateState( + state.copy( + currentUser = session.user, + isProUser = profile.isProUser, + credits = profile.credits, + isSyncEnabled = nextSyncEnabled + ) ) - ) - accountStatusMessage = if (profile.isProUser) { - "Account checked. Pro is unlocked." - } else { - "Account checked. Pro is not unlocked." + accountStatusMessage = if (profile.isProUser) { + "Account checked. Pro is unlocked." + } else { + "Account checked. Pro is not unlocked." + } + if (showBanner) updateState(state.withBanner("Account status refreshed.")) } - if (showBanner) updateState(state.withBanner("Account status refreshed.")) }.onFailure { error -> - accountStatusMessage = error.message ?: "Could not check account status." - if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + accountStatusMessage = error.message ?: "Could not check account status." + if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + } } + desktopAccountProfileRefreshCompleted = true } fun signInDesktopAccount() { @@ -607,7 +739,8 @@ internal fun EpistemeDesktopApp( desktopAuthRepository.signIn(::openExternalUrl) }.onSuccess { session -> updateState(state.copy(currentUser = session.user, isProUser = false, credits = 0)) - accountStatusMessage = "Signed in. Checking Pro and credits..." + accountStatusMessage = "Signed in. Checking account and credits..." + desktopAccountProfileRefreshCompleted = false refreshDesktopAccountProfile() }.onFailure { error -> accountStatusMessage = error.message ?: "Google sign-in failed." @@ -663,6 +796,7 @@ internal fun EpistemeDesktopApp( val details = buildList { if (result.uploadedBooks > 0) add("Uploaded ${result.uploadedBooks}.") if (result.downloadedBooks > 0) add("Downloaded ${result.downloadedBooks}.") + if (result.pendingContentDownloads > 0) add("Waiting for ${result.pendingContentDownloads} upload(s) to finish.") } return if (details.isEmpty()) { "Cloud sync complete." @@ -672,14 +806,27 @@ internal fun EpistemeDesktopApp( } fun syncDesktopCloud(showBanner: Boolean = false): Job { - desktopCloudSyncJob?.takeIf { it.isActive }?.let { return it } + desktopCloudSyncJob?.takeIf { it.isActive }?.let { + logDesktopCloudSync { "desktop.full_sync.reuse_active showBanner=$showBanner" } + return it + } val job = scope.launch { - if (!state.isSyncEnabled) return@launch - val credentials = desktopCloudSyncCredentials(showBanner) ?: return@launch + if (!state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.skip reason=sync_disabled showBanner=$showBanner" } + return@launch + } + val credentials = desktopCloudSyncCredentials(showBanner) ?: run { + logDesktopCloudSync { "desktop.full_sync.skip reason=missing_credentials showBanner=$showBanner" } + return@launch + } val snapshotState = state val snapshotShelfRecords = shelfRecords val snapshotShelfRefs = shelfRefs val snapshotFonts = customFonts + logDesktopCloudSync { + "desktop.full_sync.start user=${credentials.userId} device=${credentials.deviceId} showBanner=$showBanner " + + "books=${snapshotState.rawLibraryBooks.size} shelves=${snapshotShelfRecords.size} folderSync=${snapshotState.isFolderSyncEnabled}" + } if (showBanner) { updateState(state.copy(isRefreshing = true).withBanner("Cloud sync: checking library...")) @@ -702,6 +849,43 @@ internal fun EpistemeDesktopApp( ) } }.onSuccess { result -> + val openBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val snapshotBooksById = snapshotState.rawLibraryBooks.associateBy { it.id } + val syncedBooksById = result.state.rawLibraryBooks.associateBy { it.id } + val staleGuards = openBookIds.mapNotNull { bookId -> + val before = snapshotBooksById[bookId] ?: return@mapNotNull null + val after = syncedBooksById[bookId] ?: return@mapNotNull null + if (after.timestamp > before.timestamp && !before.hasSameCloudReaderPosition(after)) { + bookId to before + } else { + null + } + }.toMap() + if (staleGuards.isNotEmpty()) { + readerCloudStalePositionGuards = readerCloudStalePositionGuards + staleGuards + clearReaderCloudDirty(staleGuards.keys) + logDesktopCloudSync { + "desktop.full_sync.open_reader_guard books=${staleGuards.keys.joinToString()} " + + "reason=remote_advanced_while_reader_open" + } + } + logDesktopCloudSync { + "desktop.full_sync.success user=${credentials.userId} uploaded=${result.uploadedBooks} " + + "downloaded=${result.downloadedBooks} pendingContent=${result.pendingContentDownloads} " + + "books=${result.state.rawLibraryBooks.size}" + } + if (result.pendingContentDownloads <= 0) { + desktopCloudContentRetryJob?.cancel() + desktopCloudContentRetryJob = null + } else if (desktopCloudContentRetryJob?.isActive != true) { + desktopCloudContentRetryJob = scope.launch { + delay(DesktopCloudContentRetryDelayMillis) + if (state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.content_retry pending=${result.pendingContentDownloads}" } + syncDesktopCloud(showBanner = false).join() + } + } + } customFonts = result.customFonts val syncedState = result.state.copy( isSyncEnabled = state.isSyncEnabled, @@ -715,6 +899,7 @@ internal fun EpistemeDesktopApp( fonts = result.customFonts ) }.onFailure { error -> + logDesktopCloudSync { "desktop.full_sync.failed user=${credentials.userId} error=${error.message.orEmpty()}" } val failed = state.copy(isRefreshing = false) if (showBanner) { updateState(failed.withBanner(error.message ?: "Cloud sync failed.", isError = true)) @@ -761,26 +946,176 @@ internal fun EpistemeDesktopApp( } } - fun queueCloudBookMetadataSync(book: BookItem, uploadContent: Boolean = false) { + fun queueCloudBookMetadataSync( + book: BookItem, + uploadContent: Boolean = false, + debounce: Boolean = true, + dirtyBaseTimestamp: Long? = null, + forceUploadAnnotations: Boolean = false + ) { if (!state.isSyncEnabled) return if (isDesktopPdfReflowBookId(book.id)) return if (book.sourceFolder != null) return if (book.path?.startsWith("opds-pse") == true) return if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return + logDesktopCloudSync { + "desktop.book_queue.request uploadContent=$uploadContent debounce=$debounce dirtyBaseTs=$dirtyBaseTimestamp " + + "forceAnnotations=$forceUploadAnnotations ${book.desktopCloudSyncSummary()}" + } desktopBookCloudSyncJobs.remove(book.id)?.cancel() val job = scope.launch { - if (!uploadContent) delay(1_200L) - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch - val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: return@launch + if (!uploadContent && debounce) delay(1_200L) + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_credentials book=${book.id}" } + return@launch + } + val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_local book=${book.id}" } + return@launch + } if (isDesktopPdfReflowBookId(latestBook.id)) return@launch if (latestBook.sourceFolder != null) return@launch + if (latestBook.path?.startsWith("opds-pse") == true) return@launch + if (SharedFileCapabilities.isManualOnlyReaderFileName(latestBook.displayName)) return@launch if (uploadContent) { updateState(state.copy(uploadingBookIds = state.uploadingBookIds + latestBook.id)) } try { + val remoteBook = withContext(Dispatchers.IO) { + desktopFirestoreRepository.getBookMetadata( + userId = credentials.userId, + bookId = latestBook.id, + idToken = credentials.idToken + ) + } + val localSidecarTimestamp = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.localAnnotationTimestamp(latestBook) + } + val hasLocalAnnotations = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.hasLocalAnnotationData(latestBook) + } + val remoteAnnotationDriveTimestamp = if (remoteBook?.hasAnnotations == true) { + withContext(Dispatchers.IO) { + desktopGoogleDriveRepository.getFileByName( + credentials.driveAccessToken, + desktopCloudAnnotationDriveFileName(latestBook.id) + )?.modifiedTimeMillis ?: 0L + } + } else { + 0L + } + val localReadingTimestamp = latestBook.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remoteBook?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val remoteAnnotationTimestamp = remoteBook?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveTimestamp + ) ?: 0L + val latestBookForMetadata = if (remoteBook != null && remoteReadingTimestamp > localReadingTimestamp) { + latestBook.withCloudReadingPosition(remoteBook) + } else { + latestBook + } + val localFile = latestBook.path?.let(::File) + val localFileAvailable = localFile?.isFile == true + val localContentTimestamp = latestBook.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + val remoteChangedSinceDirtyStart = dirtyBaseTimestamp != null && + remoteBook != null && + remoteBook.lastModifiedTimestamp != dirtyBaseTimestamp + logDesktopCloudSync { + "desktop.book_queue.preflight book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + latestBook.desktopCloudSyncSummary() + " " + + (remoteBook?.desktopCloudSyncSummary() ?: "remote=null") + + " localSidecarTs=$localSidecarTimestamp localContentTs=$localContentTimestamp" + } + logDesktopCloudAnnotations { + "desktop.queue.inspect book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localHas=$hasLocalAnnotations localSidecarTs=$localSidecarTimestamp " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(latestBook) + } + if (remoteChangedSinceDirtyStart && !(forceUploadAnnotations && hasLocalAnnotations)) { + logDesktopCloudAnnotations { + "desktop.queue.skip_upload book=${latestBook.id} reason=remote_changed_since_dirty " + + "dirtyBaseTs=$dirtyBaseTimestamp remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L}" + } + logDesktopCloudSync { "desktop.book_queue.decision action=pull_remote_changed_since_dirty book=${latestBook.id}" } + syncDesktopCloud(showBanner = false).join() + return@launch + } + val canUploadMetadata = remoteBook == null || shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + val canUploadContent = when { + remoteBook?.isDeleted == true && canUploadMetadata -> localFileAvailable + uploadContent -> shouldUploadLocalCloudBookContent( + localFileAvailable = localFileAvailable, + localContentModifiedTimestamp = localContentTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp + ) + else -> false + } + val canUploadAnnotations = (forceUploadAnnotations && hasLocalAnnotations) || + (hasLocalAnnotations && + (remoteBook == null || + !remoteBook.hasAnnotations || + localSidecarTimestamp > remoteAnnotationTimestamp)) + val shouldApplyRemote = remoteBook != null && shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + + if (remoteBook != null && !canUploadMetadata && !canUploadContent && !canUploadAnnotations) { + logDesktopCloudAnnotations { + "desktop.queue.no_upload book=${latestBook.id} canUploadAnnotations=$canUploadAnnotations " + + "canUploadMetadata=$canUploadMetadata shouldApplyRemote=$shouldApplyRemote " + + "remoteHas=${remoteBook.hasAnnotations} remoteTs=${remoteBook.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (remoteBook.isDeleted || shouldApplyRemote) "pull_remote" else "noop"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + if (remoteBook.isDeleted || shouldApplyRemote) { + syncDesktopCloud(showBanner = false).join() + } + return@launch + } + + val usesRemoteMetadataForUpload = !canUploadMetadata && shouldApplyRemote && remoteBook != null + val bookForUpload = if (usesRemoteMetadataForUpload && remoteBook != null) { + remoteBook.toDesktopBookItem(existing = latestBook).let { remoteMetadataBook -> + if (canUploadContent) { + remoteMetadataBook.copy(fileContentModifiedTimestamp = localContentTimestamp) + } else { + remoteMetadataBook + } + } + } else { + latestBookForMetadata + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + logDesktopCloudAnnotations { + "desktop.queue.upload book=${latestBook.id} action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "canUploadAnnotations=$canUploadAnnotations canUploadMetadata=$canUploadMetadata " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } val syncedBook = withContext(Dispatchers.IO) { desktopCloudSync.uploadBookAndMetadata( input = DesktopCloudSyncInput( @@ -794,16 +1129,24 @@ internal fun EpistemeDesktopApp( customFonts = customFonts, includeFolderBooks = state.isFolderSyncEnabled ), - book = latestBook, - uploadContent = uploadContent + book = bookForUpload, + uploadContent = canUploadContent, + uploadAnnotations = canUploadAnnotations, + remoteHasAnnotations = remoteBook?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp ) } ?: return@launch + logDesktopCloudSync { + "desktop.book_queue.upload_success oldTs=${latestBook.timestamp} newTs=${syncedBook.timestamp} " + + syncedBook.desktopCloudSyncSummary("synced") + } updateState( state.copy( rawLibraryBooks = state.rawLibraryBooks.map { current -> if (current.id == syncedBook.id && current.timestamp == latestBook.timestamp) { - current.copy(timestamp = syncedBook.timestamp) + syncedBook } else { current } @@ -824,6 +1167,128 @@ internal fun EpistemeDesktopApp( } } + fun syncClosedReaderBooksIfDirty(bookIds: Set) { + val dirtyBookIds = bookIds.intersect(readerCloudDirtyBookIds) + if (dirtyBookIds.isEmpty()) return + logDesktopCloudSync { "desktop.reader.close_dirty books=${dirtyBookIds.joinToString()} requested=${bookIds.joinToString()}" } + val dirtyBooks = dirtyBookIds.mapNotNull { bookId -> + state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?.let { book -> + Triple( + book, + readerCloudDirtyBaseTimestamps[bookId], + bookId in readerCloudDirtySidecarBookIds + ) + } + } + clearReaderCloudDirty(dirtyBookIds) + dirtyBooks.forEach { (book, baseTimestamp, sidecarsDirty) -> + queueCloudBookMetadataSync( + book = book, + debounce = false, + dirtyBaseTimestamp = baseTimestamp, + forceUploadAnnotations = sidecarsDirty + ) + } + } + + fun syncClosedReaderBooksAfterDispose(bookIds: Set) { + if (bookIds.isEmpty()) return + scope.launch { + delay(DesktopReaderCloseDisposeSyncDelayMillis) + val stillClosedBookIds = bookIds + .filter { bookId -> readerWindows.none { it.bookId == bookId } } + .toSet() + closingReaderBookIds = closingReaderBookIds - bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - stillClosedBookIds + syncClosedReaderBooksIfDirty(stillClosedBookIds) + clearReaderCloudDirty(stillClosedBookIds) + } + } + + fun closeReaderWindow(windowId: String) { + logDesktopReaderClose("close_window_request windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + val closing = readerWindows.firstOrNull { it.id == windowId } ?: run { + logDesktopReaderClose("close_window_missing windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + return + } + val closingBookIds = setOf(closing.bookId) + val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + logDesktopReaderClose( + "close_window_begin windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "content=${closing.readerCloseContentLabel()} fullscreen=${closing.fullscreen} shouldStopTts=$shouldStopTts" + ) + if (desktopFeatureNoticeState?.placement?.readerWindowId == windowId) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.cancelReaderWork() + readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) + logDesktopReaderClose( + "close_window_removed windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "remainingWindows=${readerWindows.size}" + ) + updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + + fun closeReaderWindowsForBookIds(bookIds: Set) { + if (bookIds.isEmpty()) return + val closing = readerWindows.filter { it.bookId in bookIds } + val closingBookIds = closing.mapTo(mutableSetOf()) { it.bookId } + val closingWindowIds = closing.mapTo(mutableSetOf()) { it.id } + val shouldStopTts = closing.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + val targetNoticeWindowId = desktopFeatureNoticeState?.placement?.readerWindowId + if (targetNoticeWindowId != null && targetNoticeWindowId in closingWindowIds) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.forEach { it.cancelReaderWork() } + readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) + if (closingBookIds.isNotEmpty()) { + var nextState = state + closingBookIds.forEach { bookId -> + nextState = nextState.reduce(AppAction.BookTabClosed(bookId)) + } + updateState(nextState) + } + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + closingReaderBookIds = closingReaderBookIds - closingBookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - closingBookIds + clearReaderCloudDirty(closingBookIds) + } + + fun closeAllReaderWindows() { + val closingBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val shouldStopTts = readerWindows.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + markReaderBooksClosing(closingBookIds) + readerWindows.forEach { it.cancelReaderWork() } + readerWindows = emptyList() + if (desktopFeatureNoticeState?.placement?.readerWindowId != null) { + dismissDesktopFeatureNotice() + } + updateState(state.reduce(AppAction.AllTabsClosed)) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + fun syncCloudShelfChange(record: ShelfRecord, refs: List, isDeleted: Boolean = false) { if (!state.isSyncEnabled || record.isSmart) return scope.launch { @@ -891,20 +1356,21 @@ internal fun EpistemeDesktopApp( } fun updateAiByokSettings(next: ReaderAiByokSettings) { - val sanitized = next.sanitized() - val settingsToSave = if (!desktopBuildProfile.byokAiAvailable) { - aiByokSettings.sanitized().copy( - hideReaderAiFeatures = sanitized.hideReaderAiFeatures, - ttsSpeakerId = sanitized.ttsSpeakerId - ) - } else { - logDesktopTts( - "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + - "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + - "cloudAvailable=${sanitized.isCloudTtsAvailable}" - ) - sanitized + val sanitized = next.toDesktopPersistableAiSettings() + if (sanitized.ttsSpeakerId != aiByokSettings.toDesktopPersistableAiSettings().ttsSpeakerId && desktopTtsAdapter.isPlaybackActive) { + scope.launch { + snackbarHostState.showSnackbar( + desktopString("desktop_stop_reading_change_voices", "Stop reading to change voices.") + ) + } + return } + val settingsToSave = sanitized + logDesktopTts( + "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + + "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + + "cloudAvailable=${sanitized.isCloudTtsAvailable}" + ) aiByokSettings = settingsToSave readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> @@ -930,12 +1396,6 @@ internal fun EpistemeDesktopApp( } } - fun updateReaderAutoScroll(windowId: String, autoScroll: ReaderAutoScrollState) { - updateTextReaderWindow(windowId) { content -> - content.copy(extrasState = content.extrasState.copy(autoScroll = autoScroll.sanitized())) - } - } - fun textReaderTtsCacheSummary(content: DesktopReaderWindowContent.Text): ReaderTtsCacheSummary { return desktopTtsAdapter.cacheSummary( content.session.reader.book.title, @@ -955,10 +1415,15 @@ internal fun EpistemeDesktopApp( ) fun cloudTtsUnavailableMessage(): String { - return if (desktopBuildProfile.byokAiAvailable) { + return if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { desktopString( - "desktop_cloud_tts_needs_gemini_key_desc", - "Add a Gemini key and select Gemini cloud TTS in AI keys and models." + "desktop_cloud_tts_unavailable", + "Cloud TTS unavailable" + ) + } else if (!desktopByokCloudTtsAvailable && !desktopCreditCloudTtsControlsAvailable) { + desktopString( + "desktop_cloud_tts_not_configured_desc", + "Cloud TTS is not configured for this desktop build." ) } else if (state.currentUser == null) { desktopString("desktop_cloud_tts_sign_in_required_desc", "Sign in with Google to use cloud TTS.") @@ -991,12 +1456,6 @@ internal fun EpistemeDesktopApp( messageFallback = "Desktop AI is not configured for this build." ) } - if (effectiveAiSettings().hideReaderAiFeatures) { - return desktopFeatureUnavailableNotice( - messageKey = "desktop_reader_ai_hidden_desc", - messageFallback = "Reader AI features are hidden." - ) - } if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && state.currentUser == null) { return desktopSignInRequiredNotice( messageKey = "desktop_sign_in_required_multi_word_dictionary_desc", @@ -1037,8 +1496,14 @@ internal fun EpistemeDesktopApp( } fun desktopFeatureNoticeForCloudTts(): DesktopFeatureNotice? { - if (desktopBuildProfile.byokAiAvailable) return null - if (!featurePolicy.networkAccess || !desktopCloudConfig.isTtsWorkerConfigured) { + if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { + return desktopFeatureUnavailableNotice( + messageKey = "desktop_cloud_tts_not_configured_desc", + messageFallback = "Cloud TTS is not configured for this desktop build." + ) + } + if (desktopByokCloudTtsAvailable) return null + if (!desktopCreditCloudTtsControlsAvailable) { return desktopFeatureUnavailableNotice( messageKey = "desktop_cloud_tts_not_configured_desc", messageFallback = "Cloud TTS is not configured for this desktop build." @@ -1147,7 +1612,7 @@ internal fun EpistemeDesktopApp( } } desktopFeatureNoticeForReaderAi(ReaderAiFeature.SUMMARIZE, text)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } updateTextReaderWindow(windowId) { it.copy(isSummaryLoading = true, summaryResult = null) } @@ -1189,7 +1654,7 @@ internal fun EpistemeDesktopApp( isSummaryLoading = false ) } - desktopFeatureNoticeForError(result.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(result.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } @@ -1201,7 +1666,7 @@ internal fun EpistemeDesktopApp( return } desktopFeatureNoticeForReaderAi(ReaderAiFeature.RECAP, currentText)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } val book = content.session.reader.book @@ -1233,7 +1698,7 @@ internal fun EpistemeDesktopApp( pastSummaries += generated } if (summary.error != null) { - desktopFeatureNoticeForError(summary.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(summary.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } delay(500) } @@ -1258,7 +1723,7 @@ internal fun EpistemeDesktopApp( recapProgressMessage = null ) } - desktopFeatureNoticeForError(recap.error)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(recap.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } @@ -1281,7 +1746,7 @@ internal fun EpistemeDesktopApp( if (normalizedText.isBlank()) return if (!effectiveAiSettings().areReaderAiFeaturesAvailable) return desktopFeatureNoticeForReaderAi(feature, normalizedText)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = windowId) return } val aiResultRequestId = content.readerAiResultRequestId + 1 @@ -1392,22 +1857,133 @@ internal fun EpistemeDesktopApp( ) val latest = textReaderWindowContent(windowId) if (latest != null && isReaderAiResultVisible(latest, aiResultRequestId)) { - desktopFeatureNoticeForError(result.second)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(result.second)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } } } } - fun syncBookSidecars(book: BookItem) { + fun isDesktopFolderLocalSyncEnabled(sourceFolder: String?): Boolean { + if (sourceFolder.isNullOrBlank()) return false + return state.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + + fun syncBookSidecars(book: BookItem, debounceMillis: Long = 0L) { if (book.sourceFolder.isNullOrBlank()) { logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}") return } + if (!isDesktopFolderLocalSyncEnabled(book.sourceFolder)) { + logDesktopFolderSync( + "bookSidecars.skipDisabled book=${book.id} " + + "sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + return + } logDesktopFolderSync( "bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" ) - scope.launch(Dispatchers.IO) { + desktopBookSidecarSaveJobs.remove(book.id)?.cancel() + val saveJob = scope.launch(Dispatchers.IO) { + if (debounceMillis > 0L) { + delay(debounceMillis) + } DesktopLocalFolderSync.saveBookSidecars(book) } + desktopBookSidecarSaveJobs[book.id] = saveJob + saveJob.invokeOnCompletion { + desktopBookSidecarSaveJobs.remove(book.id, saveJob) + } + } + + fun scheduleFolderMetadataExtraction(sourceFolders: Set) { + val enabledSourceFolders = sourceFolders.filterTo(mutableSetOf()) { isDesktopFolderLocalSyncEnabled(it) } + if (enabledSourceFolders.isEmpty()) return + val snapshotBooks = state.rawLibraryBooks + val originalBooksById = snapshotBooks + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + if (originalBooksById.isEmpty()) return + + scope.launch { + val metadataResult = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichFolderBooks( + books = snapshotBooks, + sourceFolders = enabledSourceFolders + ) + } + if (metadataResult.stats.updatedBooks <= 0) return@launch + + val enrichedBooksById = metadataResult.books + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + val booksToSave = mutableListOf() + val mergedBooks = state.rawLibraryBooks.map { current -> + val enriched = enrichedBooksById[current.id] + ?.takeIf { current.sourceFolder in enabledSourceFolders } + ?: return@map current + val merged = current.withDesktopImportMetadata( + enriched = enriched, + original = originalBooksById[current.id] + ) + if (merged != current) booksToSave += merged + merged + } + if (booksToSave.isEmpty()) return@launch + + updateState(state.copy(rawLibraryBooks = mergedBooks)) + withContext(Dispatchers.IO) { + booksToSave.forEach { syncBook -> + DesktopLocalFolderSync.saveBookSidecars(syncBook) + } + } + } + } + + fun BookItem.matchesIncomingReaderPosition( + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val savedPage = if (type == FileType.PDF || type == FileType.PPTX || SharedFileCapabilities.isComicArchive(type)) { + lastPageIndex + } else { + readerPosition?.pageIndex ?: lastPageIndex + } + val savedProgress = progressPercentage + val progressMatches = savedProgress != null && kotlin.math.abs(savedProgress - progress) < 0.001f + val locatorMatches = session == null || readerPosition == session.navigationLocator + return savedPage == pageIndex && progressMatches && locatorMatches + } + + fun shouldIgnoreStaleReaderEcho( + bookId: String, + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val guard = readerCloudStalePositionGuards[bookId] ?: return false + if (guard.matchesIncomingReaderPosition(pageIndex, progress, session)) { + logDesktopPositionTrace { + "event=persist_skip_stale_echo bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_skip_stale_echo book=$bookId page=$pageIndex progress=$progress " + + guard.desktopCloudSyncSummary("guard") + } + return true + } + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookId + logDesktopPositionTrace { + "event=persist_stale_guard_cleared bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_guard_cleared book=$bookId page=$pageIndex progress=$progress" + } + return false } fun updateBookReadingState( @@ -1417,48 +1993,154 @@ internal fun EpistemeDesktopApp( session: ReaderSessionState? = null, pdfViewport: SharedPdfReaderViewport? = null ) { + val hasOpenReaderWindow = readerWindows.any { it.bookId == bookId } + val previousBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + logDesktopPositionTrace { + "event=persist_request bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "hasSession=${session != null} mode=${session?.reader?.settings?.readingMode ?: "none"} " + + "openWindow=$hasOpenReaderWindow closing=${bookId in closingReaderBookIds} " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "previousPage=${previousBook?.lastPageIndex ?: "null"} " + + "previousProgress=${previousBook?.progressPercentage ?: "null"} " + + "previousLocator=${previousBook?.readerPosition.desktopPositionTraceSummary()}" + } + if (!hasOpenReaderWindow && bookId !in closingReaderBookIds) { + logDesktopPositionTrace { + "event=persist_skip_closed bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + logDesktopCloudSync { + "desktop.reader.position_skip_closed book=$bookId page=$pageIndex progress=$progress" + } + return + } + if (shouldIgnoreStaleReaderEcho(bookId, pageIndex, progress, session)) return + var updatedBook: BookItem? = null var shouldSyncSidecars = false - val next = state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { book -> + var dirtyBaseTimestamp: Long? = null + if (previousBook == null) { + logDesktopPositionTrace { + "event=persist_skip_missing_book bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val textReaderSettings = session?.reader?.settings + val updatedTextReaderDefaults = textReaderSettings + ?.takeIf { it != state.readerDefaultSettings } + val readerPosition = session?.navigationLocator + val nextReaderSettings = textReaderSettings ?: previousBook.readerSettings + val nextBookmarks = session?.bookmarks ?: previousBook.readerBookmarks + val nextHighlights = session?.highlights ?: previousBook.readerHighlights + val nextPdfViewport = pdfViewport ?: previousBook.pdfReaderViewport + val progressChanged = previousBook.progressPercentage + ?.let { kotlin.math.abs(it - progress) >= DesktopProgressEpsilon } + ?: true + val isReaderDirty = + previousBook.lastPageIndex != pageIndex || + progressChanged || + previousBook.readerPosition != readerPosition || + previousBook.readerSettings != nextReaderSettings || + previousBook.readerBookmarks != nextBookmarks || + previousBook.readerHighlights != nextHighlights || + previousBook.pdfReaderViewport != nextPdfViewport + if (!isReaderDirty && updatedTextReaderDefaults == null) { + logDesktopPositionTrace { + "event=persist_skip_unchanged bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val stateWithReaderDefaults = if (updatedTextReaderDefaults != null) { + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + updatedTextReaderDefaults + ) + } else { + state + } + val next = stateWithReaderDefaults.copy( + readerDefaultSettings = textReaderSettings ?: state.readerDefaultSettings, + rawLibraryBooks = stateWithReaderDefaults.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, - pdfReaderViewport = pdfViewport ?: book.pdfReaderViewport - ).also { updatedBook = it } + shouldSyncSidecars = isReaderDirty + if (isReaderDirty && book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } + if (isReaderDirty) { + val now = System.currentTimeMillis() + book.copy( + progressPercentage = progress, + timestamp = now, + isRecent = true, + lastPageIndex = pageIndex, + readerPosition = readerPosition, + readerSettings = nextReaderSettings, + readerBookmarks = nextBookmarks, + readerHighlights = nextHighlights, + pdfReaderViewport = nextPdfViewport, + readingPositionModifiedTimestamp = now + ).also { updatedBook = it } + } else { + book + } } else { book } } ) - updateState(next) - if (shouldSyncSidecars) { - updatedBook?.let(::syncBookSidecars) + updateState( + next, + persistDebounceMillis = if (isReaderDirty) DesktopReaderPositionPersistDebounceMillis else 0L + ) + logDesktopPositionTrace { + val saved = updatedBook + "event=persist_done bookId=\"${bookId.logPreview(80)}\" updated=${saved != null} " + + "requestedPage=$pageIndex requestedProgress=$progress " + + "savedPage=${saved?.lastPageIndex ?: "null"} savedProgress=${saved?.progressPercentage ?: "null"} " + + "savedLocator=${saved?.readerPosition.desktopPositionTraceSummary()} " + + "shouldSyncSidecars=$shouldSyncSidecars dirtyBaseTimestamp=${dirtyBaseTimestamp ?: "null"}" + } + if (updatedTextReaderDefaults != null) { + readerWindows = readerWindows.map { windowState -> + val content = windowState.content + if (content is DesktopReaderWindowContent.Text && + content.session.reader.settings != updatedTextReaderDefaults + ) { + windowState.copy( + content = content.copy( + session = readerEngine.updateSettings(content.session, updatedTextReaderDefaults) + ) + ) + } else { + windowState + } + } + } + if (shouldSyncSidecars) { + updatedBook?.let { book -> + syncBookSidecars(book, debounceMillis = DesktopReaderPositionPersistDebounceMillis) + } + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) } - updatedBook?.let { queueCloudBookMetadataSync(it) } } fun updateBookReaderSettings(bookId: String, settings: ReaderSettings) { + val pdfSettings = settings.toDesktopPdfReaderSettings() var updatedBook: BookItem? = null - val next = state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { book -> + var dirtyBaseTimestamp: Long? = null + val stateWithPdfDefaults = state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + pdfSettings + ) + val next = stateWithPdfDefaults.copy( + rawLibraryBooks = stateWithPdfDefaults.rawLibraryBooks.map { book -> if (book.id == bookId) { + if (book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } book.copy( timestamp = System.currentTimeMillis(), isRecent = true, - readerSettings = settings + readerSettings = pdfSettings ).also { updatedBook = it } } else { book @@ -1467,7 +2149,7 @@ internal fun EpistemeDesktopApp( ) updateState(next) updatedBook?.let(::syncBookSidecars) - updatedBook?.let { queueCloudBookMetadataSync(it) } + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) } fun importDesktopReaderTexture(settings: ReaderSettings): ReaderSettings? { @@ -1512,6 +2194,8 @@ internal fun EpistemeDesktopApp( fun signOutDesktopAccount() { desktopAuthRepository.signOut() + desktopAccountProfileRepository.clearCachedProfiles() + desktopAccountProfileRefreshCompleted = true stopReaderCloudTts() saveDesktopCloudSyncSettings(syncEnabled = false) updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) @@ -1570,12 +2254,36 @@ internal fun EpistemeDesktopApp( } } - fun startReaderCloudTts(windowId: String, readScope: ReaderTtsReadScope, chunks: List) { + fun startReaderCloudTts( + windowId: String, + readScope: ReaderTtsReadScope, + chunks: List, + startChunkIndex: Int = 0, + restartActive: Boolean = false, + applyReplacements: Boolean = true + ) { val content = textReaderWindowContent(windowId) ?: return val replacementBookId = content.book.id.ifBlank { content.session.reader.book.title } - val ttsChunks = chunks - .filter { it.text.isNotBlank() } - .withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + val sourceChunks = chunks.filter { it.text.isNotBlank() } + logDesktopTtsStartTrace { + "event=desktop_start_request windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "incomingChunks=${chunks.size} sourceChunks=${sourceChunks.size} startChunkIndex=$startChunkIndex " + + "restartActive=$restartActive applyReplacements=$applyReplacements " + + "currentPage=${content.session.reader.currentPageIndex} sessionLocator=${content.session.navigationLocator.desktopPositionTraceSummary(160)} " + + "incomingFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "sourceFirst=${sourceChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } + val ttsChunks = if (applyReplacements) { + sourceChunks.withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + } else { + sourceChunks + } + logDesktopTtsStartTrace { + "event=desktop_start_prepared windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "ttsChunks=${ttsChunks.size} boundedStart=${startChunkIndex.coerceIn(0, ttsChunks.lastIndex.coerceAtLeast(0))} " + + "first=${ttsChunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "second=${ttsChunks.getOrNull(1).desktopTtsStartTraceSummary(160)}" + } val settings = aiByokSettings.sanitized() val currentCloudTts = content.extrasState.cloudTts logDesktopTts( @@ -1584,10 +2292,14 @@ internal fun EpistemeDesktopApp( "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + "available=${desktopTtsAdapter.isAvailable}" ) - if (currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused) { + val ttsActive = currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused + if (ttsActive && !restartActive) { stopReaderCloudTts(windowId) return } + if (ttsActive) { + content.ttsJob?.cancel() + } if (ttsChunks.isEmpty()) { logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}") updateTextReaderWindow(windowId) { latest -> @@ -1604,7 +2316,7 @@ internal fun EpistemeDesktopApp( } if (!desktopTtsAdapter.isAvailable) { logDesktopTts("reader_sequence_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } updateTextReaderWindow(windowId) { latest -> latest.copy( extrasState = latest.extrasState.copy( @@ -1638,11 +2350,18 @@ internal fun EpistemeDesktopApp( } } val ttsSessionId = System.currentTimeMillis() + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) + logDesktopTtsStartTrace { + "event=desktop_playback_window windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "boundedStart=$boundedStartChunkIndex playbackChunks=${playbackChunks.size} " + + "playbackFirst=${playbackChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } val initialProgress = ReaderTtsProgress( sessionId = ttsSessionId, scope = readScope, chunks = ttsChunks, - currentChunkIndex = -1 + currentChunkIndex = boundedStartChunkIndex - 1 ) updateTextReaderWindow(windowId) { latest -> latest.copy( @@ -1661,11 +2380,24 @@ internal fun EpistemeDesktopApp( ) ) } + fun updateTextReaderTtsSession(transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text) { + updateTextReaderWindow(windowId) { latest -> + if (latest.extrasState.cloudTts.progress.sessionId == ttsSessionId) { + transform(latest) + } else { + latest + } + } + } val ttsJob = scope.launch { runCatching { - logDesktopTts("reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") - desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, ttsChunks) { index -> + logDesktopTts( + "reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, playbackChunks) { relativeIndex -> if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex val chunk = ttsChunks[index] val progress = initialProgress.copy(currentChunkIndex = index) val latest = textReaderWindowContent(windowId) @@ -1680,7 +2412,7 @@ internal fun EpistemeDesktopApp( session = updatedSession ) } - updateTextReaderWindow(windowId) { current -> + updateTextReaderTtsSession { current -> current.copy( extrasState = current.extrasState.copy( cloudTts = ReaderCloudTtsState( @@ -1700,11 +2432,14 @@ internal fun EpistemeDesktopApp( "sourceCfi=\"${chunk.sourceCfi.orEmpty().logPreview()}\" chars=${chunk.text.length} " + "text=\"${chunk.text.logPreview()}\"" ) + logDesktopTtsStartTrace { + "event=desktop_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "chunk=${chunk.desktopTtsStartTraceSummary(180)}" + } } }.onFailure { error -> logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") - if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() - updateTextReaderWindow(windowId) { latest -> + updateTextReaderTtsSession { latest -> if (error is kotlinx.coroutines.CancellationException) { latest.copy( ttsJob = null, @@ -1716,7 +2451,7 @@ internal fun EpistemeDesktopApp( ) ) } else { - desktopFeatureNoticeForError(error.message)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(error.message)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } latest.copy( ttsJob = null, extrasState = latest.extrasState.copy( @@ -1731,7 +2466,7 @@ internal fun EpistemeDesktopApp( } }.onSuccess { logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}") - updateTextReaderWindow(windowId) { latest -> + updateTextReaderTtsSession { latest -> latest.copy( ttsJob = null, extrasState = latest.extrasState.copy( @@ -1747,7 +2482,37 @@ internal fun EpistemeDesktopApp( updateTextReaderWindow(windowId) { latest -> latest.copy(ttsJob = ttsJob) } } - fun toggleReaderCloudTts(windowId: String, text: String) { + fun skipReaderCloudTtsChunk(windowId: String, delta: Int) { + val content = textReaderWindowContent(windowId) ?: return + val progress = content.extrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startReaderCloudTts( + windowId = windowId, + readScope = progress.scope, + chunks = progress.chunks, + startChunkIndex = targetIndex, + restartActive = true, + applyReplacements = false + ) + } + + fun locateReaderCloudTtsChunk(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + val chunk = content.extrasState.cloudTts.progress.currentChunk ?: return + val updatedSession = readerEngine.goToPage(content.session, chunk.pageIndex) + updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } + updateBookReadingState( + bookId = content.book.id, + pageIndex = updatedSession.reader.currentPageIndex, + progress = updatedSession.reader.progress, + session = updatedSession + ) + } + + fun toggleReaderCloudTts(windowId: String, text: String, locator: ReaderLocator? = null) { val content = textReaderWindowContent(windowId) ?: return val normalizedText = text.trim() val settings = aiByokSettings.sanitized() @@ -1780,7 +2545,7 @@ internal fun EpistemeDesktopApp( } if (!desktopTtsAdapter.isAvailable) { logDesktopTts("reader_toggle_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } updateTextReaderWindow(windowId) { latest -> latest.copy( extrasState = latest.extrasState.copy( @@ -1794,14 +2559,24 @@ internal fun EpistemeDesktopApp( } return } - val page = content.session.reader.currentPage - val selectionChunks = if (page != null) { + val locatorChunks = locator + ?.takeIf { it.startOffset != null || !it.cfi.isNullOrBlank() } + ?.let { selectionLocator -> + ReaderTtsPlanner.chunksFromCurrentLocation( + content.session.copy(navigationLocator = selectionLocator) + ).takeIf { it.isNotEmpty() } + } + val page = locator + ?.pageIndex + ?.let { content.session.reader.pages.getOrNull(it) } + ?: content.session.reader.currentPage + val selectionChunks = locatorChunks ?: if (page != null) { ReaderTtsPlanner.chunksForText( text = normalizedText, - pageIndex = page.pageIndex, - chapterIndex = page.chapterIndex, + pageIndex = locator?.pageIndex ?: page.pageIndex, + chapterIndex = locator?.chapterIndex ?: page.chapterIndex, chapterTitle = page.chapterTitle, - sourceStartOffset = page.startOffset + sourceStartOffset = locator?.startOffset ?: page.startOffset ) } else { ReaderTtsPlanner.chunksForText( @@ -1811,7 +2586,11 @@ internal fun EpistemeDesktopApp( chapterTitle = desktopString("desktop_selection", "Selection") ) } - startReaderCloudTts(windowId, ReaderTtsReadScope.PAGE, selectionChunks) + startReaderCloudTts( + windowId = windowId, + readScope = if (locatorChunks != null) ReaderTtsReadScope.BOOK else ReaderTtsReadScope.PAGE, + chunks = selectionChunks + ) } fun finishImportFiles( @@ -1938,7 +2717,7 @@ internal fun EpistemeDesktopApp( book.withDesktopImportMetadata( enriched = enriched, original = originalTargetBooksById[book.id] - ) + ).copy(timestamp = System.currentTimeMillis()) } ) ) @@ -1989,6 +2768,11 @@ internal fun EpistemeDesktopApp( updateState(state.withBanner("No local folders are linked yet.", isError = true)) return } + if (targetFolder == null && state.syncedFolders.none { it.localSyncEnabled }) { + logDesktopFolderSync("ui.sync.skipNoEnabledFolders mode=$mode") + updateState(state.withBanner("No local folders have sync enabled.", isError = true)) + return + } val snapshotState = state val snapshotShelfRefs = shelfRefs @@ -2002,14 +2786,22 @@ internal fun EpistemeDesktopApp( } scope.launch { - val result = withContext(Dispatchers.IO) { - DesktopLocalFolderSync.sync( - state = snapshotState, - shelfRefs = snapshotShelfRefs, - targetFolder = targetFolder, - metadataOnly = metadataOnly - ) - } + val result = runCatching { + withContext(Dispatchers.IO) { + DesktopLocalFolderSync.sync( + state = snapshotState, + shelfRefs = snapshotShelfRefs, + targetFolder = targetFolder, + metadataOnly = metadataOnly, + extractMetadata = false + ) + } + }.onFailure { error -> + logDesktopFolderSync("ui.sync.failed mode=$mode error=${error.folderSyncSummary()}") + if (showBanner) { + updateState(state.withBanner(error.message ?: "Folder sync failed.", isError = true)) + } + }.getOrNull() ?: return@launch val failedCount = result.failedFolders.size val stats = result.stats val metadataStats = result.metadataStats @@ -2040,20 +2832,24 @@ internal fun EpistemeDesktopApp( "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 { - result.state - } + val completedState = desktopFolderSyncCompletedState( + state = result.state, + message = message, + failedFolderCount = failedCount, + showBanner = showBanner + ) replaceLibrary( completedState, refs = result.shelfRefs ) + if (!metadataOnly) { + scheduleFolderMetadataExtraction(result.processedFolderUris.toSet()) + } val existingBookIds = completedState.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } readerWindows = readerWindows.mapNotNull { window -> val migratedBookId = result.idMigrations[window.bookId] ?: window.bookId if (migratedBookId !in existingBookIds) { - window.closeReaderResources() + window.cancelReaderWork() null } else if (migratedBookId != window.bookId) { val migratedContent = when (val content = window.content) { @@ -2091,7 +2887,7 @@ internal fun EpistemeDesktopApp( fun syncDesktopLibrary(showBanner: Boolean = true) { val hasCloud = state.isSyncEnabled - val hasFolders = state.syncedFolders.isNotEmpty() + val hasFolders = state.syncedFolders.any { it.localSyncEnabled } if (!hasCloud && !hasFolders) { updateState(state.withBanner("No sync methods are active.", isError = true)) return @@ -2203,6 +2999,22 @@ internal fun EpistemeDesktopApp( } } + fun createShelfWithBooks(name: String, bookIds: Set, clearSelection: Boolean = true) { + SharedLibraryEditor.createShelfWithBooks( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + name = name, + bookIds = bookIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.lastOrNull { record -> record.name == name.trim() } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + fun createSmartShelf(name: String, definition: SmartCollectionDefinition) { SharedLibraryEditor.createSmartShelf(state, shelfRecords, shelfRefs, name, definition, System.currentTimeMillis())?.let { replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) @@ -2238,6 +3050,33 @@ internal fun EpistemeDesktopApp( } } + fun addBooksToShelves(bookIds: Set, shelfIds: Set, clearSelection: Boolean) { + val targetShelfIds = shelfIds.filterTo(linkedSetOf()) { SharedLibraryEditor.canMutateShelf(it) } + SharedLibraryEditor.addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = bookIds, + shelfIds = targetShelfIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + targetShelfIds.forEach { shelfId -> + result.shelfRecords.firstOrNull { record -> record.id == shelfId } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + } + + fun replaceShelfBooks(shelf: Shelf, bookIds: Set) { + SharedLibraryEditor.replaceShelfBooks(state, shelfRecords, shelfRefs, shelf.id, bookIds, System.currentTimeMillis())?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.firstOrNull { record -> record.id == shelf.id } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + fun tagSelectedBooks(tagName: String) { SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let { replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) @@ -2298,7 +3137,9 @@ internal fun EpistemeDesktopApp( } rewritten.onSuccess(::applyBookMetadataUpdate) .onFailure { error -> - println("Failed to update EPUB metadata for ${updated.displayName}: ${error.message}") + logDesktopDiagnostic("EpistemeDesktopMetadata") { + "epub_metadata_update_failed book=${updated.id} error=\"${error.message.orEmpty().logPreview()}\"" + } updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata."))) } } @@ -2312,10 +3153,9 @@ internal fun EpistemeDesktopApp( val now = System.currentTimeMillis() val next = SharedLibraryEditor.markBookOpened(state, bookId, now) val openedState = next.reduce(AppAction.BookTabOpened(bookId)) - updateState(openedState) + updateState(openedState, persistDebounceMillis = DesktopLibraryOpenPersistDebounceMillis) openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let { book -> - syncBookSidecars(book) - queueCloudBookMetadataSync(book) + syncBookSidecars(book, debounceMillis = DesktopLibraryOpenPersistDebounceMillis) } } @@ -2330,13 +3170,16 @@ internal fun EpistemeDesktopApp( rawLibraryBooks = state.rawLibraryBooks.map { current -> if (current.id == book.id) { current.withDesktopImportMetadata(enriched = enriched, original = book) + .copy(timestamp = System.currentTimeMillis()) } else { current } } ) ) - state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { queueCloudBookMetadataSync(it) } + state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { + markReaderCloudDirty(it.id, baseTimestamp = book.timestamp) + } } } @@ -2362,14 +3205,23 @@ internal fun EpistemeDesktopApp( } fun exitReaderTo(tab: SharedAppTab) { - selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY + if (tab == SharedAppTab.SHELVES) { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + selectedTab = SharedAppTab.LIBRARY + } else { + selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY + } } fun selectAppTab(tab: SharedAppTab) { - val nextTab = if (tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs) { - SharedAppTab.HOME - } else { - tab + val nextTab = when { + tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs -> SharedAppTab.LIBRARY + tab == SharedAppTab.PRO && !desktopAccountAvailable() -> SharedAppTab.LIBRARY + tab == SharedAppTab.SHELVES -> { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + SharedAppTab.LIBRARY + } + else -> tab } if (nextTab == SharedAppTab.SETTINGS) { settingsQuery = "" @@ -2385,12 +3237,48 @@ internal fun EpistemeDesktopApp( } } + fun focusDesktopAppWindow() { + val ownerWindow = (window as? java.awt.Window) + ?: window?.let { javax.swing.SwingUtilities.getWindowAncestor(it) } + ?: return + EventQueue.invokeLater { + if (!ownerWindow.isDisplayable || !ownerWindow.isShowing) return@invokeLater + if (ownerWindow is java.awt.Frame && ownerWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { + ownerWindow.extendedState = ownerWindow.extendedState and java.awt.Frame.ICONIFIED.inv() + } + ownerWindow.toFront() + ownerWindow.requestFocus() + ownerWindow.requestFocusInWindow() + } + } + + fun confirmDesktopFeatureNotice(notice: DesktopFeatureNotice) { + dismissDesktopFeatureNotice() + when (notice.action) { + DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() + DesktopFeatureNoticeAction.OPEN_PRO -> { + selectAppTab(SharedAppTab.PRO) + focusDesktopAppWindow() + } + null -> Unit + } + } + fun applyReaderOpenResult(result: DesktopReaderOpenResult) { + val applyStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_start") + + " result=${result.openTraceKind()}" + } val window = readerWindows.firstOrNull { it.opening.requestId == result.opening.requestId } if (window == null) { if (result is DesktopReaderOpenResult.Pdf) { result.document.close() } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_missing_window") + + " result=${result.openTraceKind()} durationMs=${applyStartedAt.elapsedOpenTraceMs()}" + } return } @@ -2411,10 +3299,6 @@ internal fun EpistemeDesktopApp( } is DesktopReaderOpenResult.Pdf -> { - (window.content as? DesktopReaderWindowContent.Pdf) - ?.document - ?.takeIf { it.handleId != result.document.handleId } - ?.close() readerWindows = readerWindows.withDesktopReaderWindowContent( requestId = result.opening.requestId, content = DesktopReaderWindowContent.Pdf( @@ -2447,6 +3331,11 @@ internal fun EpistemeDesktopApp( recordBookOpened(result.book.id) } } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_done") + + " result=${result.openTraceKind()} windowId=\"${window.id.logPreview(80)}\" " + + "durationMs=${applyStartedAt.elapsedOpenTraceMs()} openWindows=${readerWindows.size}" + } } fun openReader( @@ -2456,10 +3345,6 @@ internal fun EpistemeDesktopApp( returnTabOverride: SharedAppTab? = null ) { val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) - if (shouldRequestDesktopWebViewRuntime(desktopReaderSurface)) { - webViewRuntimeRequested = true - } - if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { val path = book.path if (path.isNullOrBlank()) { @@ -2502,23 +3387,57 @@ internal fun EpistemeDesktopApp( ?: SharedAppTab.LIBRARY, password = password ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_request") + + " type=${book.type} surface=$desktopReaderSurface force=$force " + + "path=\"${book.path.orEmpty().logPreview(180)}\"" + } val readerDefaultSettings = state.readerDefaultSettings + val previousWindowCount = readerWindows.size if (force) { - readerWindows.firstOrNull { it.bookId == book.id }?.closeReaderResources() + readerWindows.firstOrNull { it.bookId == book.id }?.let { existingWindow -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_force_cancel_existing") + + " windowId=\"${existingWindow.id.logPreview(80)}\"" + } + existingWindow.cancelReaderWork() + } } val openDecision = readerWindows.openOrFocusDesktopReaderWindow(opening, force) readerWindows = openDecision.windows + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_window_decision") + + " shouldStart=${openDecision.shouldStartOpen} force=$force " + + "previousWindows=$previousWindowCount nextWindows=${openDecision.windows.size}" + } if (!openDecision.shouldStartOpen) { recordBookOpened(book.id) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_focus_existing_done") + } return } scope.launch { + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_coroutine_start") + } val result = withContext(Dispatchers.IO) { + val ioStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_start") + + " surface=$desktopReaderSurface" + } runCatching { when (desktopReaderSurface) { ReaderFeatureSurface.PDF_VIEWER -> { + val pdfStartedAt = System.nanoTime() val path = book.path.orEmpty() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_start") + + " type=${book.type} path=\"${path.logPreview(180)}\" " + + "passwordSupplied=${!opening.password.isNullOrEmpty()}" + } val streamReference = SharedOpdsStreamUri.parse(path) val document = if (streamReference != null) { DesktopPdfium.loadOpdsStream( @@ -2539,19 +3458,77 @@ internal fun EpistemeDesktopApp( else -> DesktopPdfium.loadComic(readerFile, book.type) } } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_done") + + " type=${book.type} durationMs=${pdfStartedAt.elapsedOpenTraceMs()} " + + "pages=${document.pageCount}" + } DesktopReaderOpenResult.Pdf(opening, book, document) } ReaderFeatureSurface.EPUB_READER, ReaderFeatureSurface.TEXT_READER -> { val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.") + val readerFile = File(path) + val settingsStartedAt = System.nanoTime() + val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + val semanticMode = if (restoredSettings.readingMode == ReaderReadingMode.VERTICAL) { + SharedJvmBookLoadSemanticMode.SKIP + } else { + SharedJvmBookLoadSemanticMode.FULL + } + val preparedHtmlChapterRange = if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) { + val initialChapter = book.readerPosition?.chapterIndex?.takeIf { it >= 0 } ?: 0 + (initialChapter - DesktopVerticalInitialPreparedHtmlChapterRadius).coerceAtLeast(0).. + (initialChapter + DesktopVerticalInitialPreparedHtmlChapterRadius) + } else { + null + } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_settings_restored") + + " durationMs=${settingsStartedAt.elapsedOpenTraceMs()} " + + "mode=${restoredSettings.readingMode} semanticMode=${semanticMode.name} " + + "preparedHtmlChapters=${preparedHtmlChapterRange?.let { "${it.first}..${it.last}" } ?: "all"} " + + "fontSize=${restoredSettings.fontSize} textAlign=${restoredSettings.textAlign} " + + "pageWidth=${restoredSettings.pageWidth}" + } + val loadStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_start") + + " type=${book.type} semanticMode=${semanticMode.name} fileBytes=${readerFile.length()} " + + "path=\"${path.logPreview(180)}\"" + } val loadedBook = SharedJvmBookLoader.load( - file = File(path), + file = readerFile, type = book.type, titleOverride = book.title?.takeIf { it.isNotBlank() }, - authorOverride = book.author?.takeIf { it.isNotBlank() } + authorOverride = book.author?.takeIf { it.isNotBlank() }, + semanticMode = semanticMode, + preparedHtmlChapterRange = preparedHtmlChapterRange ) - val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_done") + + " durationMs=${loadStartedAt.elapsedOpenTraceMs()} " + + "loadedTitle=\"${loadedBook.title.logPreview(120)}\" " + + "chapters=${loadedBook.chapters.size} pagesBeforeSession=n/a " + + "textChars=${loadedBook.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${loadedBook.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${loadedBook.chapters.sumOf { it.semanticBlocks.size }} " + + "cssFiles=${loadedBook.css.size} cssChars=${loadedBook.css.values.sumOf { it.length }}" + } + val sessionStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_start") + + " initialPage=${book.lastPageIndex ?: 0} hasLocator=${book.readerPosition != null} " + + "locator=${book.readerPosition.desktopPositionTraceSummary(70)} " + + "bookmarks=${book.readerBookmarks.size} highlights=${book.readerHighlights.size}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_start") + + " initialPage=${book.lastPageIndex ?: 0} storedProgress=${book.progressPercentage ?: "null"} " + + "storedLocator=${book.readerPosition.desktopPositionTraceSummary()} " + + "mode=${restoredSettings.readingMode}" + } val restoredSession = readerEngine.createSession( book = loadedBook, settings = restoredSettings, @@ -2560,9 +3537,42 @@ internal fun EpistemeDesktopApp( bookmarks = book.readerBookmarks, highlights = book.readerHighlights ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "pages=${restoredSession.reader.pages.size} " + + "currentPage=${restoredSession.reader.currentPageIndex + 1} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary(70)} " + + "visiblePages=${restoredSession.reader.visiblePages.map { it.pageIndex + 1 }}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_session_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "page=${restoredSession.reader.currentPageIndex} pages=${restoredSession.reader.pages.size} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary()}" + } val restoredProgress = book.progressPercentage val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) { + val progressStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_start") + + " progress=$restoredProgress" + } readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) + .also { restored -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "currentPage=${restored.reader.currentPageIndex + 1} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary(70)}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_progress_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "page=${restored.reader.currentPageIndex} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary()}" + } + } } else { restoredSession } @@ -2572,6 +3582,11 @@ internal fun EpistemeDesktopApp( else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.") } }.getOrElse { error -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_exception") + + " durationMs=${ioStartedAt.elapsedOpenTraceMs()} " + + "error=\"${error.message.orEmpty().logPreview(240)}\"" + } if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER && book.type == FileType.PDF && error.isDesktopPdfPasswordException() @@ -2589,8 +3604,17 @@ internal fun EpistemeDesktopApp( (error.message ?: "unknown error") ) } + }.also { loadedResult -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_done") + + " result=${loadedResult.openTraceKind()} durationMs=${ioStartedAt.elapsedOpenTraceMs()}" + } } } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_result_ready") + + " result=${result.openTraceKind()}" + } applyReaderOpenResult(result) } } @@ -2844,8 +3868,18 @@ internal fun EpistemeDesktopApp( } val latestReaderWindows by rememberUpdatedState(readerWindows) + val latestStateForDispose by rememberUpdatedState(state) + val latestShelfRecordsForDispose by rememberUpdatedState(shelfRecords) + val latestShelfRefsForDispose by rememberUpdatedState(shelfRefs) + val latestCustomFontsForDispose by rememberUpdatedState(customFonts) DisposableEffect(Unit) { onDispose { + flushDesktopPersistenceBeforeDispose( + projected = latestStateForDispose, + records = latestShelfRecordsForDispose, + refs = latestShelfRefsForDispose, + fonts = latestCustomFontsForDispose + ) latestReaderWindows.forEach { it.closeReaderResources() } } } @@ -2868,9 +3902,10 @@ internal fun EpistemeDesktopApp( } } - LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser) { + LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser, desktopAccountProfileRefreshCompleted) { if ( !initialDesktopCloudSyncDone && + desktopAccountProfileRefreshCompleted && state.isSyncEnabled && state.currentUser != null && state.isProUser @@ -2881,7 +3916,7 @@ internal fun EpistemeDesktopApp( } LaunchedEffect(Unit) { - if (state.syncedFolders.isNotEmpty()) { + if (state.syncedFolders.any { it.localSyncEnabled }) { scanSyncedFolders(showBanner = false) } } @@ -2920,18 +3955,35 @@ internal fun EpistemeDesktopApp( appSeedColor = state.appSeedColor, appFontFamily = desktopAppFontFamily ) { - EpistemeDesktopWindowChromeEffect( - window = window, - captionColor = MaterialTheme.colorScheme.surface, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - SharedAppShell( + val appThemeControls: @Composable () -> Unit = { + SharedAppThemeControls( + 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))) } + ) + } + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + SharedAppShell( selectedTab = selectedTab, snackbarHostState = snackbarHostState, appThemeMode = state.appThemeMode, @@ -2942,6 +3994,23 @@ internal fun EpistemeDesktopApp( customAppThemes = state.customAppThemes, isTabsEnabled = state.isTabsEnabled, featurePolicy = featurePolicy, + currentUser = if (desktopAccountAvailable()) state.currentUser else null, + accountAvailable = desktopAccountAvailable(), + isOssBuild = desktopBuildProfile.isOssOffline, + isProUser = state.isProUser, + isSyncEnabled = state.isSyncEnabled, + syncAvailable = desktopCloudSyncAvailable(), + onSignInRequested = if (desktopAccountAvailable()) { + ::signInDesktopAccount + } else { + null + }, + accountAvatar = { user, modifier -> + DesktopProfileAvatar(user = user, modifier = modifier) + }, + onSyncEnabledChange = { enabled -> + setDesktopCloudSyncEnabled(enabled) + }, onTabSelected = { tab -> selectAppTab(tab) }, @@ -2960,46 +4029,13 @@ internal fun EpistemeDesktopApp( if (!enabled) closeAllReaderWindows() updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) }, - onAiSettingsRequested = if (desktopBuildProfile.byokAiAvailable) { + onAiSettingsRequested = if (desktopAiKeySettingsAvailable) { { showAiByokSettingsDialog = true } } else { null } ) { tab -> when (tab) { - SharedAppTab.HOME -> HomeScreen( - state = state, - selectedLibraryTab = selectedLibraryTab, - onLibraryTabChange = { selectedLibraryTab = it }, - onStateChange = ::updateState, - onImportBooks = { - importFiles(chooseFiles()) - }, - onImportFolder = { chooseFolder()?.let(::importFolder) }, - onRead = ::openReader, - onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, - onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { - bookInfoInitiallyEditing = false - bookInfoDialogFor = it - }, - onEditBook = { - bookInfoInitiallyEditing = true - bookInfoDialogFor = it - }, - onCreateShelf = { showCreateShelfDialog = true }, - onCreateSmartShelf = { showCreateSmartShelfDialog = true }, - onRenameShelf = { shelfToRename = it }, - onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it }, - onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, - onSyncFolderMetadata = { syncFolderMetadata() }, - onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } - ) - SharedAppTab.SETTINGS -> SharedSettingsHub( model = sharedSettingsHubModel( SharedSettingsHubInput( @@ -3009,19 +4045,20 @@ internal fun EpistemeDesktopApp( isSignedIn = state.currentUser != null, isProUser = state.isProUser, accountAvailable = featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable, + includeAccountAuthActions = false, syncAvailable = desktopCloudSyncAvailable(), folderSyncAvailable = true, - aiSettingsAvailable = desktopBuildProfile.byokAiAvailable, + aiSettingsAvailable = desktopAiKeySettingsAvailable, includeLanguage = true, includeScreenCaptureProtection = false, includeExternalFileBehavior = false, includeStrictFileFilter = false, includeReaderTabs = false, - includeHideReaderAi = featurePolicy.aiAndCloud, + includeHideReaderAi = false, isTabsEnabled = state.isTabsEnabled, isSyncEnabled = state.isSyncEnabled, isFolderSyncEnabled = state.isFolderSyncEnabled, - hideReaderAi = effectiveAiSettings().hideReaderAiFeatures, + hideReaderAi = false, languageTitle = desktopString("options_language", "Language"), languageSummary = selectedDesktopLanguageOption(desktopLanguageTag).let { option -> desktopString(option.labelKey, option.fallbackLabel) @@ -3034,11 +4071,21 @@ internal fun EpistemeDesktopApp( onDestinationChange = { settingsDestination = it }, readerDefaultSettings = state.readerDefaultSettings, onReaderDefaultSettingsChange = { settings -> - updateState(state.reduce(AppAction.ReaderDefaultSettingsChanged(settings))) + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + settings + ) + ) }, pdfReaderDefaultSettings = state.pdfReaderDefaultSettings, onPdfReaderDefaultSettingsChange = { settings -> - updateState(state.reduce(AppAction.PdfReaderDefaultSettingsChanged(settings))) + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + settings + ) + ) }, readerToolbarPreferences = state.readerToolbarPreferences, onReaderToolbarPreferencesChange = { preferences -> @@ -3050,6 +4097,10 @@ internal fun EpistemeDesktopApp( }, customFonts = customFonts, onPickCustomFont = { importCustomFont(chooseFontFile())?.path }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = ::importDesktopReaderTexture, onAction = { action -> @@ -3060,14 +4111,10 @@ internal fun EpistemeDesktopApp( updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) } SharedSettingsAction.FOLDER_SYNC -> setDesktopFolderSyncEnabled(!state.isFolderSyncEnabled) - SharedSettingsAction.AI_SETTINGS -> if (desktopBuildProfile.byokAiAvailable) showAiByokSettingsDialog = true + SharedSettingsAction.AI_SETTINGS -> if (desktopAiKeySettingsAvailable) showAiByokSettingsDialog = true SharedSettingsAction.SIGN_IN -> signInDesktopAccount() SharedSettingsAction.SIGN_OUT -> signOutDesktopAccount() - SharedSettingsAction.HIDE_READER_AI -> { - val next = aiByokSettings.copy(hideReaderAiFeatures = !effectiveAiSettings().hideReaderAiFeatures) - aiByokSettings = next - runCatching { aiByokStore.save(next.sanitized()) } - } + SharedSettingsAction.HIDE_READER_AI -> Unit SharedSettingsAction.CUSTOM_FONTS -> selectAppTab(SharedAppTab.CUSTOM_FONTS) SharedSettingsAction.HELP_FEEDBACK -> selectAppTab(SharedAppTab.FEEDBACK) SharedSettingsAction.SUPPORT -> selectAppTab(SharedAppTab.SUPPORT) @@ -3136,24 +4183,47 @@ internal fun EpistemeDesktopApp( bookInfoInitiallyEditing = true bookInfoDialogFor = it }, - onCreateShelf = { showCreateShelfDialog = true }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, onDeleteShelf = { shelfToDelete = it }, onRemoveFolder = { folderToRemove = it }, onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, onSyncFolderMetadata = { syncFolderMetadata() }, onScanFolders = { scanSyncedFolders() }, onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } ) - SharedAppTab.SHELVES -> ShelvesScreen( - shelves = state.shelves, + SharedAppTab.SHELVES -> LibraryScreen( + state = state, + selectedLibraryTab = NonReaderLibraryTab.SHELVES, + onLibraryTabChange = { + selectedLibraryTab = it + selectedTab = SharedAppTab.LIBRARY + }, + onStateChange = ::updateState, + onImportBooks = { + importFiles(chooseFiles()) + }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, onRead = ::openReader, onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - selectedBookIds = state.selectedBookIds, - pinnedBookIds = state.pinnedLibraryBookIds, + onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, + onRemoveSelected = ::removeSelectedBooks, onShowBookInfo = { bookInfoInitiallyEditing = false bookInfoDialogFor = it @@ -3162,12 +4232,29 @@ internal fun EpistemeDesktopApp( bookInfoInitiallyEditing = true bookInfoDialogFor = it }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, - onCreateShelf = { showCreateShelfDialog = true }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it } + onRemoveFolder = { folderToRemove = it }, + onTagSelectedBooks = { showTagSelectionDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, + onSyncFolderMetadata = { syncFolderMetadata() }, + onScanFolders = { scanSyncedFolders() }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } ) SharedAppTab.CATALOGS -> { @@ -3243,6 +4330,21 @@ internal fun EpistemeDesktopApp( { openExternalUrl(EpistemeIssuesUrl) } } else { null + }, + onOpenPrivacyPolicy = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.privacyPolicyUrl) } + } else { + null + }, + onOpenTerms = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.termsUrl) } + } else { + null + }, + onOpenLicenses = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.licensesUrl) } + } else { + null } ) @@ -3253,18 +4355,48 @@ internal fun EpistemeDesktopApp( } readerWindows.forEach { readerWindow -> - key(readerWindow.id) { + key(readerWindow.id, readerWindow.surfaceResetId) { + val restoredReaderWindowState = savedReaderWindowState val windowState = rememberWindowState( + placement = restoredReaderWindowState?.toReaderWindowPlacement() ?: WindowPlacement.Floating, position = WindowPosition(Alignment.Center), - size = DpSize(1120.dp, 760.dp) + size = restoredReaderWindowState?.toWindowSize(DesktopReaderWindowDefaultSize) + ?: DesktopReaderWindowDefaultSize ) Window( - onCloseRequest = { closeReaderWindow(readerWindow.id) }, + onCloseRequest = { + logDesktopReaderClose( + "window_on_close_request windowId=${readerWindow.id.logPreview(80)} " + + "bookId=${readerWindow.bookId.logPreview(80)} fullscreen=${readerWindow.fullscreen}" + ) + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot(DesktopWindowStateSnapshot.fromWindowState(windowState)) + } + closeReaderWindow(readerWindow.id) + }, title = desktopString("desktop_label_pair_format", "%1\$s - %2\$s", readerWindow.title, readerWindowDefaults.title), state = windowState, icon = painterResource(readerWindowDefaults.iconResourcePath) ) { val readerAwtWindow = this.window + val latestReaderWindowForDispose by rememberUpdatedState(readerWindow) + DisposableEffect(readerWindow.id) { + onDispose { + logDesktopReaderClose( + "window_dispose_effect windowId=${latestReaderWindowForDispose.id.logPreview(80)} " + + "bookId=${latestReaderWindowForDispose.bookId.logPreview(80)} " + + "content=${latestReaderWindowForDispose.readerCloseContentLabel()}" + ) + latestReaderWindowForDispose.closeReaderResources() + } + } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = readerWindowStateStore, + enabled = !readerWindow.fullscreen, + transformSnapshot = { it.toPersistableReaderWindowSnapshot() }, + onSnapshotSaved = { savedReaderWindowState = it } + ) DisposableEffect(readerAwtWindow, readerWindowDefaults.minimumSize) { readerAwtWindow.minimumSize = readerWindowDefaults.minimumSize onDispose {} @@ -3310,11 +4442,18 @@ internal fun EpistemeDesktopApp( ) { when (val content = readerWindow.content) { DesktopReaderWindowContent.Opening -> { - DesktopReaderOpeningScreen(opening = readerWindow.opening) + val openingBook = state.rawLibraryBooks.firstOrNull { it.id == readerWindow.bookId } + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = openingBook?.let { resolvedDesktopReaderSettings(it, state.readerDefaultSettings) } + ) } is DesktopReaderWindowContent.PasswordRequired -> { - DesktopReaderOpeningScreen(opening = readerWindow.opening) + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = resolvedDesktopReaderSettings(content.book, state.readerDefaultSettings) + ) DesktopPdfPasswordDialog( title = content.book.displayName, isError = content.attemptedPassword, @@ -3350,6 +4489,7 @@ internal fun EpistemeDesktopApp( onFullscreenChange = { enabled -> updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } }, + appThemeControls = appThemeControls, onPageStateChange = { page, progress, viewport -> updateBookReadingState( bookId = content.book.id, @@ -3365,12 +4505,26 @@ internal fun EpistemeDesktopApp( onPdfHighlighterPaletteChange = { palette -> updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette))) }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, customTextureIds = readerCustomTextureIds, onImportTexture = ::importDesktopReaderTexture, onLocalSidecarsChanged = { state.rawLibraryBooks.firstOrNull { it.id == content.book.id }?.let { book -> syncBookSidecars(book) - queueCloudBookMetadataSync(book) + markReaderCloudDirty( + bookId = book.id, + baseTimestamp = book.timestamp, + sidecarsDirty = true + ) + queueCloudBookMetadataSync( + book = book, + debounce = true, + dirtyBaseTimestamp = book.timestamp, + forceUploadAnnotations = true + ) } }, aiByokSettings = effectiveAiSettings(), @@ -3382,23 +4536,26 @@ internal fun EpistemeDesktopApp( }, summaryCacheStore = desktopSummaryCacheStore, credits = state.credits, - showPaidCredits = !desktopBuildProfile.byokAiAvailable, + showPaidCredits = desktopCloudTtsUsesCredits, onAiByokSettingsChange = ::updateAiByokSettings, featurePolicy = featurePolicy, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, onReaderAiEntitlementRequired = { feature, text -> desktopFeatureNoticeForReaderAi(feature, text)?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) true } ?: false }, onCloudTtsEntitlementRequired = { desktopFeatureNoticeForCloudTts()?.let { notice -> - desktopFeatureNotice = notice + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) true } ?: false }, onPaidFeatureError = { errorMessage -> - desktopFeatureNoticeForError(errorMessage)?.let { desktopFeatureNotice = it } + desktopFeatureNoticeForError(errorMessage)?.let { + showDesktopFeatureNotice(it, readerWindowId = readerWindow.id) + } }, hasReflowFile = activePdfHasReflowFile, isReflowingThisBook = activePdfBook.id in reflowingPdfBookIds, @@ -3411,6 +4568,40 @@ internal fun EpistemeDesktopApp( } is DesktopReaderWindowContent.Text -> { + var previousTextReaderMode by remember(readerWindow.id) { + mutableStateOf(content.session.reader.settings.readingMode) + } + LaunchedEffect(content.session.reader.settings.readingMode) { + val previousMode = previousTextReaderMode + val currentMode = content.session.reader.settings.readingMode + previousTextReaderMode = currentMode + if ( + shouldResetDesktopTextReaderWindowSurface( + previousMode = previousMode, + currentMode = currentMode, + usesNativeWebView = desktopEpubWebViewUsesNativeSwtBrowser() + ) + ) { + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot( + DesktopWindowStateSnapshot.fromWindowState(windowState) + ) + } + logReaderModeSwitch( + "window_surface_reset_request windowId=${readerWindow.id.logPreview()} " + + "bookId=${readerWindow.bookId.logPreview()} previousMode=$previousMode currentMode=$currentMode " + + "surfaceResetId=${readerWindow.surfaceResetId} fullscreen=${readerWindow.fullscreen} " + + "windowState=${windowState.size.width.value.formatLogFloat()}x" + + "${windowState.size.height.value.formatLogFloat()} placement=${windowState.placement}" + ) + updateReaderWindow(readerWindow.id) { currentWindow -> + currentWindow.copy( + surfaceResetId = currentWindow.surfaceResetId + 1, + focusRequestId = currentWindow.focusRequestId + 1 + ) + } + } + } LaunchedEffect( readerWindow.id, content.session.reader.book.id, @@ -3437,10 +4628,16 @@ internal fun EpistemeDesktopApp( onFullscreenChange = { enabled -> updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } }, + readerAwtWindow = readerAwtWindow, toolbarPreferences = state.readerToolbarPreferences, onToolbarPreferencesChange = { preferences -> updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) }, + appThemeControls = appThemeControls, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, highlightPalette = state.readerHighlightPalette, onHighlightPaletteChange = { palette -> updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette))) @@ -3457,7 +4654,7 @@ internal fun EpistemeDesktopApp( readerExtrasState = content.extrasState, aiByokSettings = effectiveAiSettings(), externalLookupAvailable = featurePolicy.externalLookup, - cloudTtsControlsAvailable = featurePolicy.aiAndCloud, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, onExternalLookup = ::openReaderExternalLookup, onAiAction = { feature, text -> runReaderAiAction(readerWindow.id, feature, text) @@ -3470,68 +4667,46 @@ internal fun EpistemeDesktopApp( ) } }, - onCloudTtsToggle = { text -> toggleReaderCloudTts(readerWindow.id, text) }, + onCloudTtsToggle = { text, locator -> toggleReaderCloudTts(readerWindow.id, text, locator) }, onCloudTtsStart = { readScope, chunks -> startReaderCloudTts(readerWindow.id, readScope, chunks) }, onCloudTtsPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, onCloudTtsStop = { stopReaderCloudTts(readerWindow.id) }, onCloudTtsClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, + onCloudTtsVoiceChange = { voiceId -> + updateAiByokSettings(effectiveAiSettings().copy(ttsSpeakerId = voiceId)) + }, onOpenAiHub = { updateTextReaderWindow(readerWindow.id) { current -> current.copy(showAiHub = true) } }, - onAutoScrollChange = { autoScroll -> - updateReaderAutoScroll(readerWindow.id, autoScroll) - }, onDownloadReaderImage = ::downloadReaderImage, readerTextureDataUri = DesktopReaderTextures::dataUriFor, readerCustomTextureIds = readerCustomTextureIds, onImportReaderTexture = ::importDesktopReaderTexture, bottomChromeExtraContent = { - if (featurePolicy.aiAndCloud) { + if (desktopCloudTtsControlsAvailable) { val settings = effectiveAiSettings() - val ttsActive = content.extrasState.cloudTts.isLoading || - content.extrasState.cloudTts.isPlaying || - content.extrasState.cloudTts.isPaused - if (content.showCloudTtsSettings) { - DesktopCloudTtsSettingsOverlay( + var isTtsOverlayCollapsed by remember(readerWindow.id) { mutableStateOf(false) } + val ttsControls = readerCloudTtsControlsModel(content.extrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( settings = settings, - isTtsActive = ttsActive, - showCredits = !desktopBuildProfile.byokAiAvailable, + cloudTts = content.extrasState.cloudTts, credits = state.credits, - cacheSummary = content.extrasState.cloudTts.cacheSummary, - onClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, - onSettingsChange = { next -> - updateAiByokSettings( - aiByokSettings.sanitized().copy( - ttsSpeakerId = next.sanitized().ttsSpeakerId - ) - ) - } + showCredits = desktopCloudTtsUsesCredits, + isCollapsed = isTtsOverlayCollapsed, + onCollapseChange = { isTtsOverlayCollapsed = it }, + onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, + onSkipPrevious = { skipReaderCloudTtsChunk(readerWindow.id, -1) }, + onSkipNext = { skipReaderCloudTtsChunk(readerWindow.id, 1) }, + onLocateCurrentChunk = { locateReaderCloudTtsChunk(readerWindow.id) }, + onClose = { stopReaderCloudTts(readerWindow.id) }, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) ) } - DesktopCloudTtsChromeControls( - settings = settings, - cloudTts = content.extrasState.cloudTts, - credits = state.credits, - showCredits = !desktopBuildProfile.byokAiAvailable, - onRead = { - startReaderCloudTts( - readerWindow.id, - ReaderTtsReadScope.BOOK, - ReaderTtsPlanner.chunksFromCurrentLocation(content.session) - ) - }, - onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, - onStop = { stopReaderCloudTts(readerWindow.id) }, - onOpenSettings = { - updateTextReaderWindow(readerWindow.id) { current -> - current.copy(showCloudTtsSettings = !current.showCloudTtsSettings) - } - } - ) } }, webViewRuntimeState = webViewRuntimeState, @@ -3568,11 +4743,20 @@ internal fun EpistemeDesktopApp( } }, credits = state.credits, - showCredits = !desktopBuildProfile.byokAiAvailable + showCredits = desktopCloudTtsUsesCredits ) } } } + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInReaderWindow(readerWindow.id) } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } } } } @@ -3580,38 +4764,17 @@ internal fun EpistemeDesktopApp( } } - desktopFeatureNotice?.let { notice -> - AlertDialog( - onDismissRequest = { desktopFeatureNotice = null }, - title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, - text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, - confirmButton = { - TextButton( - onClick = { - desktopFeatureNotice = null - when (notice.action) { - DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() - DesktopFeatureNoticeAction.OPEN_PRO -> selectAppTab(SharedAppTab.PRO) - null -> Unit - } - } - ) { - Text(readerString(notice.confirmKey, notice.confirmFallback)) - } - }, - dismissButton = if (notice.action != null) { - { - TextButton(onClick = { desktopFeatureNotice = null }) { - Text(readerString("action_not_now", "Not now")) - } - } - } else { - null - } - ) - } + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInMainWindow() } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } - if (showAiByokSettingsDialog && desktopBuildProfile.byokAiAvailable) { + if (showAiByokSettingsDialog && desktopAiKeySettingsAvailable) { DesktopAiByokSettingsDialog( settings = aiByokSettings, secureStorageAvailable = aiByokStore.isSecureStorageAvailable, @@ -3672,10 +4835,20 @@ internal fun EpistemeDesktopApp( label = readerString("shelf_name_hint", "Shelf name"), initialValue = "", confirmLabel = readerString("action_create", "Create"), - onDismiss = { showCreateShelfDialog = false }, - onConfirm = { name -> - createShelf(name) + onDismiss = { showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false + }, + onConfirm = { name -> + if (createShelfBookIds.isEmpty()) { + createShelf(name) + } else { + createShelfWithBooks(name, createShelfBookIds, clearSelection = createShelfClearsSelection) + } + showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false } ) } @@ -3737,17 +4910,36 @@ internal fun EpistemeDesktopApp( ) } - if (showAddToShelfDialog) { + if (addToShelfBookIds.isNotEmpty()) { SharedAddToShelfDialog( shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" }, - onDismiss = { showAddToShelfDialog = false }, + onDismiss = { + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + }, onCreateShelf = { - showAddToShelfDialog = false + createShelfBookIds = addToShelfBookIds + createShelfClearsSelection = addToShelfClearsSelection + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false showCreateShelfDialog = true }, - onShelfSelected = { shelf -> - addSelectedBooksToShelf(shelf.id) - showAddToShelfDialog = false + onShelvesSelected = { shelfIds -> + addBooksToShelves(addToShelfBookIds, shelfIds, clearSelection = addToShelfClearsSelection) + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + } + ) + } + + shelfToManageBooks?.let { shelf -> + SharedManageShelfBooksDialog( + shelf = shelf, + books = state.rawLibraryBooks, + onDismiss = { shelfToManageBooks = null }, + onSave = { bookIds -> + replaceShelfBooks(shelf, bookIds) + shelfToManageBooks = null } ) } @@ -3797,6 +4989,33 @@ internal fun EpistemeDesktopApp( } } +@Composable +private fun DesktopFeatureNoticeDialog( + notice: DesktopFeatureNotice, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, + text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(readerString(notice.confirmKey, notice.confirmFallback)) + } + }, + dismissButton = if (notice.action != null) { + { + TextButton(onClick = onDismiss) { + Text(readerString("action_not_now", "Not now")) + } + } + } else { + null + } + ) +} + private fun desktopSignInRequiredNotice( messageKey: String, messageFallback: String @@ -3822,7 +5041,7 @@ private fun desktopOutOfCreditsNotice( messageKey = messageKey, messageFallback = messageFallback, confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View Pro and credits", + confirmFallback = "View account & credits", action = DesktopFeatureNoticeAction.OPEN_PRO ) } @@ -3837,7 +5056,7 @@ private fun desktopProRequiredNotice( messageKey = messageKey, messageFallback = messageFallback, confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View Pro and credits", + confirmFallback = "View account & credits", action = DesktopFeatureNoticeAction.OPEN_PRO ) } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt index c559812..c38a562 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt @@ -86,6 +86,54 @@ class DesktopAiByokStoreTest { assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) } + @Test + fun `save with blank key clears protected secret entry`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-clear").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "gemini_secret", + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + store.save( + ReaderAiByokSettings( + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + + val loaded = store.load() + assertEquals("", loaded.geminiKey) + assertEquals("groq_secret", loaded.groqKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + + @Test + fun `load ignores legacy hidden reader ai preference on desktop`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-visible").resolve("ai-byok.properties") + settingsFile.writeText( + """ + hideReaderAiFeatures=true + modelForAll=groq:qwen/qwen3-32b + useOneModel=true + """.trimIndent() + ) + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + val loaded = store.load() + + assertFalse(loaded.hideReaderAiFeatures) + assertTrue(loaded.useOneModel) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + @Test fun `load does not probe secure storage when settings file is missing`() { val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties") diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAuthStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAuthStoreTest.kt new file mode 100644 index 0000000..fe001ba --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAuthStoreTest.kt @@ -0,0 +1,85 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.UserData +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAuthStoreTest { + @Test + fun `save protects refresh tokens and load restores the account`() { + val settingsFile = Files.createTempDirectory("reader-auth-store") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ReversibleSecretCodec) + + store.save(testSession()) + + val raw = settingsFile.readText() + assertFalse(raw.contains("firebase_refresh")) + assertFalse(raw.contains("google_refresh")) + assertTrue(raw.contains("firebaseRefreshTokenProtected=")) + assertTrue(raw.contains("googleRefreshTokenProtected=")) + + val loaded = DesktopAuthStore(settingsFile, ReversibleSecretCodec).load() + assertEquals("user-1", loaded?.user?.uid) + assertEquals("reader@example.com", loaded?.user?.email) + assertEquals("firebase_refresh", loaded?.refreshToken) + assertEquals("google_refresh", loaded?.googleRefreshToken) + } + + @Test + fun `save fails without leaving a partial account file when secure storage is unavailable`() { + val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec) + + assertFailsWith { + store.save(testSession()) + } + assertFalse(settingsFile.exists()) + } + + private fun testSession(): DesktopAuthSession { + return DesktopAuthSession( + user = UserData( + uid = "user-1", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" + ), + idToken = "id_token", + refreshToken = "firebase_refresh", + expiresAtEpochMillis = 123L, + googleAccessToken = "google_access", + googleRefreshToken = "google_refresh", + googleAccessTokenExpiresAtEpochMillis = 456L + ) + } + + private object ReversibleSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + return "test:" + value.reversed() + } + + override fun unprotect(value: String): String { + return value.removePrefix("test:").reversed() + } + } + + private object ThrowingSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = false + + override fun protect(value: String): String { + throw IllegalStateException("Secure storage unavailable") + } + + override fun unprotect(value: String): String = "" + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt index 403c10f..e27cde3 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopBuildProfileTest.kt @@ -1,9 +1,8 @@ package com.aryan.reader.desktop +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID import com.aryan.reader.shared.ReaderAiByokSettings 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 @@ -18,9 +17,13 @@ class DesktopBuildProfileTest { assertEquals(EpistemeDesktopStandardAppName, profile.appName) assertEquals("Standard edition", profile.buildLabel) assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/terms-and-conditions.html")) assertTrue(profile.featurePolicy.networkAccess) assertFalse(profile.featurePolicy.byokAi) assertFalse(profile.byokAiAvailable) + assertTrue(profile.aiKeySettingsAvailable) + assertTrue(profile.creditBackedCloudTtsControlsAvailable) } @Test @@ -31,12 +34,16 @@ class DesktopBuildProfileTest { assertEquals(EpistemeDesktopOssAppName, profile.appName) assertEquals("Offline OSS edition", profile.buildLabel) assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/oss-privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/oss-terms-of-service.html")) assertFalse(profile.featurePolicy.networkAccess) assertFalse(profile.featurePolicy.aiAndCloud) assertTrue(profile.featurePolicy.byokAi) assertFalse(profile.byokAiAvailable) + assertFalse(profile.aiKeySettingsAvailable) assertFalse(profile.featurePolicy.opdsCatalogs) assertFalse(profile.featurePolicy.googleFontsDownload) + assertFalse(profile.creditBackedCloudTtsControlsAvailable) } @Test @@ -54,23 +61,104 @@ class DesktopBuildProfileTest { geminiKey = "gemini_secret", modelForAll = "gemini:gemini-flash-lite-latest" ) - val onlineOssPolicy = SharedFeaturePolicy( - networkAccess = true, - aiAndCloud = true, - byokAi = true + val onlineOssPolicy = SharedFeaturePolicy.OssOnline + + val onlineOssProfile = DesktopBuildProfile( + flavor = "oss-online", + appName = "Episteme oss", + buildLabel = "OSS edition", + featurePolicy = onlineOssPolicy ) - assertTrue( + assertTrue(onlineOssProfile.byokAiAvailable) + assertFalse(onlineOssProfile.aiKeySettingsAvailable) + assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy)) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures) + + val byokCloudTtsSettings = settings.copy( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + val desktopByokSettings = byokCloudTtsSettings.withDesktopFeaturePolicy(onlineOssPolicy) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, desktopByokSettings.ttsModel) + assertTrue(desktopByokSettings.isCloudTtsAvailable) + assertFalse( DesktopBuildProfile( flavor = "oss-online", appName = "Episteme oss", buildLabel = "OSS edition", featurePolicy = onlineOssPolicy - ).byokAiAvailable + ).creditBackedCloudTtsControlsAvailable ) - assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy)) - assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures) - assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures) + } + + @Test + fun `desktop tts worker requires its own configured endpoint`() { + val config = DesktopCloudConfig( + aiWorkerUrl = "https://example.com/ai", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "", + googleOAuthClientId = "", + googleOAuthClientSecret = "" + ) + + assertTrue(config.isAiWorkerConfigured) + assertFalse(config.isTtsWorkerConfigured) + } + + @Test + fun `desktop cloud tts adapter allows byok before credit worker`() { + val byokAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { + ReaderAiByokSettings( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + }, + networkAccess = { true }, + workerUrlProvider = { "" } + ) + val workerAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings(serverBackedCloudTts = true) }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + val unavailableAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings() }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + + assertTrue(byokAdapter.isAvailable) + assertTrue(workerAdapter.isAvailable) + assertFalse(unavailableAdapter.isAvailable) + } + + @Test + fun `desktop persisted AI settings keep Android model controls and force visibility`() { + val settings = ReaderAiByokSettings( + geminiKey = " gemini_secret ", + groqKey = " groq_secret ", + useOneModel = true, + modelForAll = "groq:qwen/qwen3-32b", + defineModel = "gemini:gemini-flash-lite-latest", + summarizeModel = "groq:llama-3.3-70b-versatile", + recapModel = "gemini:gemini-2.5-flash-lite", + hideReaderAiFeatures = true + ) + + val persisted = settings.toDesktopPersistableAiSettings() + + assertEquals("gemini_secret", persisted.geminiKey) + assertEquals("groq_secret", persisted.groqKey) + assertTrue(persisted.useOneModel) + assertEquals("groq:qwen/qwen3-32b", persisted.modelForAll) + assertEquals("gemini:gemini-flash-lite-latest", persisted.defineModel) + assertEquals("groq:llama-3.3-70b-versatile", persisted.summarizeModel) + assertEquals("gemini:gemini-2.5-flash-lite", persisted.recapModel) + assertFalse(persisted.hideReaderAiFeatures) } @Test @@ -84,36 +172,4 @@ class DesktopBuildProfileTest { 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/DesktopCloudConfigTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudConfigTest.kt new file mode 100644 index 0000000..89ad488 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudConfigTest.kt @@ -0,0 +1,60 @@ +package com.aryan.reader.desktop + +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopCloudConfigTest { + @Test + fun `packaged resource config can enable desktop Google sign in`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "firebase-key", + "FIREBASE_PROJECT_ID" to "reader-project", + "GOOGLE_OAUTH_CLIENT_ID" to "oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("firebase-key", config.firebaseWebApiKey) + assertEquals("reader-project", config.firebaseProjectId) + assertEquals("oauth-client", config.googleOAuthClientId) + assertEquals("oauth-secret", config.googleOAuthClientSecret) + } + + @Test + fun `local desktop keys override packaged resource config`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "packaged-firebase-key", + "FIREBASE_PROJECT_ID" to "packaged-project", + "GOOGLE_OAUTH_CLIENT_ID" to "packaged-oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "packaged-oauth-secret" + ), + localProperties = properties( + "DESKTOP_FIREBASE_WEB_API_KEY" to "local-firebase-key", + "DESKTOP_FIREBASE_PROJECT_ID" to "local-project", + "DESKTOP_GOOGLE_OAUTH_CLIENT_ID" to "local-oauth-client", + "DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" to "local-oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("local-firebase-key", config.firebaseWebApiKey) + assertEquals("local-project", config.firebaseProjectId) + assertEquals("local-oauth-client", config.googleOAuthClientId) + assertEquals("local-oauth-secret", config.googleOAuthClientSecret) + } +} + +private fun properties(vararg values: Pair): Properties { + return Properties().apply { + values.forEach { (key, value) -> setProperty(key, value) } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt index 351bb6f..060b811 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCloudSyncMappingTest.kt @@ -5,10 +5,16 @@ import com.aryan.reader.shared.FileType import com.aryan.reader.shared.HighlightColor import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer +import com.aryan.reader.shared.pdf.SharedPdfBookmark +import com.aryan.reader.shared.pdf.SharedPdfReaderViewport +import com.aryan.reader.shared.pdf.SharedPdfRichDocument +import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer import com.aryan.reader.shared.reader.ReaderBookmark import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopCloudSyncMappingTest { @@ -71,6 +77,7 @@ class DesktopCloudSyncMappingTest { assertEquals(2, metadata.lastChapterIndex) assertEquals(4, metadata.lastPage) assertEquals(42f, metadata.progressPercentage) + assertEquals(1_000L, metadata.readingPositionModifiedTimestamp) assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44")) assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text")) assertEquals(book.id, restored.id) @@ -88,6 +95,212 @@ class DesktopCloudSyncMappingTest { assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi) } + @Test + fun `metadata only upload can preserve remote content timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + fileContentModifiedTimestamp = 111L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = false, + timestamp = 2_000L, + contentTimestampOverride = 999L + ) + + assertEquals(999L, metadata.fileContentModifiedTimestamp) + } + + @Test + fun `metadata upload keeps reading position timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 12, + progressPercentage = 20f, + readingPositionModifiedTimestamp = 1_500L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = true, timestamp = 3_000L) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(1_500L, metadata.readingPositionModifiedTimestamp) + assertEquals(0L, metadata.annotationModifiedTimestamp) + assertEquals(12, metadata.lastPage) + } + + @Test + fun `metadata upload keeps annotation timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = true, + timestamp = 3_000L, + annotationModifiedTimestamp = 2_250L + ) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(2_250L, metadata.annotationModifiedTimestamp) + assertEquals(2_250L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + } + + @Test + fun `annotation freshness does not fall back to book metadata timestamp`() { + val metadata = DesktopCloudBookMetadata( + bookId = "book-1", + type = FileType.PDF.name, + lastModifiedTimestamp = 5_000L, + hasAnnotations = true + ) + + assertEquals(0L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + assertEquals(3_000L, metadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L)) + } + + @Test + fun `desktop drive file names use shared cloud content extension`() { + assertEquals("book-1.epub", desktopCloudBookDriveFileName("book-1", FileType.EPUB)) + assertEquals("book-1.md", desktopCloudBookDriveFileName("book-1", FileType.MD)) + assertEquals("book-1.mobi", desktopCloudBookDriveFileName("book-1", FileType.MOBI)) + assertNull(desktopCloudBookDriveFileName("book-1", FileType.UNKNOWN)) + } + + @Test + fun `empty epub annotations upload as empty arrays`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.epub", + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals("[]", metadata.bookmarksJson) + assertEquals("[]", metadata.highlightsJson) + } + + @Test + fun `remote pdf metadata moves stale desktop viewport to remote page`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 264), + pdfReaderViewport = SharedPdfReaderViewport( + pageIndex = 264, + verticalFirstPageIndex = 264, + verticalFirstPageScrollOffset = 120 + ) + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 2_000L, + lastPage = 69, + progressPercentage = 8.75f + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(69, restored.lastPageIndex) + assertEquals(8.75f, restored.progressPercentage) + assertNull(restored.readerPosition) + assertEquals(69, restored.pdfReaderViewport?.pageIndex) + assertEquals(69, restored.pdfReaderViewport?.verticalFirstPageIndex) + assertEquals(0, restored.pdfReaderViewport?.verticalFirstPageScrollOffset) + } + + @Test + fun `remote metadata with older reading timestamp preserves newer local pdf position`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 4_000L, + lastPageIndex = 88, + progressPercentage = 44f, + pdfReaderViewport = SharedPdfReaderViewport(pageIndex = 88, verticalFirstPageIndex = 88), + readingPositionModifiedTimestamp = 4_000L + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 6_000L, + readingPositionModifiedTimestamp = 3_000L, + lastPage = 12, + progressPercentage = 6f, + hasAnnotations = true + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(6_000L, restored.timestamp) + assertEquals(88, restored.lastPageIndex) + assertEquals(44f, restored.progressPercentage) + assertEquals(88, restored.pdfReaderViewport?.pageIndex) + assertEquals(4_000L, restored.readingPositionModifiedTimestamp) + } + + @Test + fun `pdf metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 69) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(264, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + + @Test + fun `comic metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.cbt", + type = FileType.CBT, + displayName = "Book.cbt", + timestamp = 1_000L, + lastPageIndex = 42, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 12) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(42, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + @Test fun `remote metadata without annotation json preserves existing desktop annotations`() { val existingBookmark = ReaderBookmark( @@ -127,4 +340,52 @@ class DesktopCloudSyncMappingTest { assertEquals(listOf(existingHighlight), restored.readerHighlights) assertEquals(existing.path, restored.path) } + + @Test + fun `desktop pdf bookmarks map to android metadata json`() { + val metadataJson = desktopPdfBookmarksMetadataJson( + bookmarks = listOf( + SharedPdfBookmark( + pageIndex = 3, + label = "Important page", + createdAt = 1_234L + ) + ), + lastPageIndex = 9 + ) + + val restored = desktopPdfBookmarksFromMetadataJson(metadataJson) + + assertTrue(metadataJson.contains("\"pageIndex\"")) + assertTrue(metadataJson.contains("\"title\"")) + assertTrue(metadataJson.contains("\"totalPages\"")) + assertEquals(1, restored.size) + assertEquals(3, restored.single().pageIndex) + assertEquals("Important page", restored.single().label) + } + + @Test + fun `android pdf bookmark metadata keeps titles on desktop`() { + val restored = desktopPdfBookmarksFromMetadataJson( + """[{"pageIndex":2,"title":"Android bookmark","totalPages":8}]""" + ) + + assertEquals(1, restored.size) + assertEquals(2, restored.single().pageIndex) + assertEquals("Android bookmark", restored.single().label) + } + + @Test + fun `empty desktop pdf annotations are not exported as cloud annotation data`() { + val emptyAnnotationsJson = SharedPdfAnnotationSerializer.encode(emptyList()) + + assertNull(desktopPdfAnnotationElementForSync(emptyAnnotationsJson)) + } + + @Test + fun `empty desktop pdf rich text is not exported as cloud annotation data`() { + val emptyRichTextJson = SharedPdfRichTextSerializer.encode(SharedPdfRichDocument()) + + assertNull(desktopPdfRichTextElementForSync(emptyRichTextJson)) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt index f465330..fde0e1b 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt @@ -1,6 +1,8 @@ package com.aryan.reader.desktop import com.aryan.reader.shared.FileType +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import java.io.File import java.nio.file.Files import java.util.Base64 @@ -35,11 +37,65 @@ class DesktopComicArchiveTest { } } + @Test + fun `closing stale comic document does not close replacement with same path`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val staleDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + val activeDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + staleDocument.close() + + val image = DesktopPdfium.renderPageBufferedImage(activeDocument, pageIndex = 0, scale = 4f) + + assertEquals(4, image.width) + assertEquals(4, image.height) + } finally { + staleDocument.close() + activeDocument.close() + } + } + + @Test + fun `cbt archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbt = File(dir, "comic.cbt") + TarArchiveOutputStream(cbt.outputStream()).use { tar -> + val bytes = onePixelPngBytes() + val entry = TarArchiveEntry("pages/001.png").apply { + size = bytes.size.toLong() + } + tar.putArchiveEntry(entry) + tar.write(bytes) + tar.closeArchiveEntry() + tar.finish() + } + + val document = DesktopPdfium.loadComic(cbt, FileType.CBT) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + @Test fun `desktop comic types are routed through shared reader capability map`() { assertTrue(DesktopComicArchive.canLoad(FileType.CBZ)) assertTrue(DesktopComicArchive.canLoad(FileType.CBR)) assertTrue(DesktopComicArchive.canLoad(FileType.CB7)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBT)) } private fun withTempDir(block: (File) -> Unit) { diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt index 2b5a4b2..f8f396a 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt @@ -7,7 +7,7 @@ class DesktopComposeInteropTest { @Test fun `desktop enables Compose interop blending before app startup`() { withSystemProperty(ComposeInteropBlendingProperty, null) { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) } @@ -16,7 +16,7 @@ class DesktopComposeInteropTest { @Test fun `desktop treats blank Compose interop blending value as unset`() { withSystemProperty(ComposeInteropBlendingProperty, " ") { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) } @@ -25,7 +25,7 @@ class DesktopComposeInteropTest { @Test fun `desktop preserves explicit Compose interop blending override`() { withSystemProperty(ComposeInteropBlendingProperty, "false") { - configureComposeSwingInterop() + configureComposeSwingInterop(nonNativeWebViewPlatform) assertEquals("false", System.getProperty(ComposeInteropBlendingProperty)) } @@ -52,4 +52,11 @@ class DesktopComposeInteropTest { } } } + + private companion object { + val nonNativeWebViewPlatform = DesktopPlatform( + os = DesktopOperatingSystem.OTHER, + architecture = DesktopArchitecture.X64 + ) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsingTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsingTest.kt new file mode 100644 index 0000000..e79f5cd --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsingTest.kt @@ -0,0 +1,112 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.ReaderLocator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopEpubBridgeParsingTest { + @Test + fun `reader position bridge keeps semantic locator fields`() { + val position = """ + { + "pageIndex": 12, + "chapterIndex": 2, + "chapterId": "chap-2", + "href": "text/chapter2.xhtml", + "startOffset": 140, + "endOffset": 140, + "blockIndex": 9, + "charOffset": 140, + "textQuote": "quoted text", + "cfi": "desktop-scroll:10:100:/4/2:3" + } + """.trimIndent().readerPositionOrNull() + + assertEquals(12, position?.pageIndex) + assertEquals(2, position?.locator?.chapterIndex) + assertEquals("chap-2", position?.locator?.chapterId) + assertEquals("text/chapter2.xhtml", position?.locator?.href) + assertEquals(9, position?.locator?.blockIndex) + assertEquals(140, position?.locator?.charOffset) + assertEquals("quoted text", position?.locator?.textQuote) + assertEquals("/4/2:3", position?.locator?.cfi) + } + + @Test + fun `locator json sent to web view includes semantic position fields`() { + val json = ReaderLocator( + chapterIndex = 2, + chapterId = "chap-2", + href = "text/chapter2.xhtml", + pageIndex = 12, + startOffset = 140, + endOffset = 155, + blockIndex = 9, + charOffset = 140, + textQuote = "quoted text", + cfi = "/4/2:3" + ).toReaderLocatorJson() + + assertTrue(json.contains("\"chapterId\":\"chap-2\"")) + assertTrue(json.contains("\"href\":\"text/chapter2.xhtml\"")) + assertTrue(json.contains("\"blockIndex\":9")) + assertTrue(json.contains("\"charOffset\":140")) + } + + @Test + fun `selection action bridge keeps locator fields for selected tts`() { + val payload = """ + { + "action": "speak", + "text": "selected text", + "locator": { + "chapterIndex": 3, + "chapterId": "chap-3", + "href": "text/chapter3.xhtml", + "pageIndex": 41, + "startOffset": 900, + "endOffset": 913, + "blockIndex": 7, + "charOffset": 900, + "textQuote": "selected text", + "cfi": "desktop-scroll:10:20:/4/8:12|/4/8:25" + } + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.SPEAK, payload?.action) + assertEquals("selected text", payload?.text) + assertEquals(3, payload?.locator?.chapterIndex) + assertEquals("chap-3", payload?.locator?.chapterId) + assertEquals("text/chapter3.xhtml", payload?.locator?.href) + assertEquals(41, payload?.locator?.pageIndex) + assertEquals(900, payload?.locator?.startOffset) + assertEquals(913, payload?.locator?.endOffset) + assertEquals(7, payload?.locator?.blockIndex) + assertEquals(900, payload?.locator?.charOffset) + assertEquals("selected text", payload?.locator?.textQuote) + assertEquals("/4/8:12|/4/8:25", payload?.locator?.cfi) + } + + @Test + fun `selection action bridge parses highlight palette manager action`() { + val payload = """ + { + "action": "palette", + "text": "selected text" + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.PALETTE, payload?.action) + assertEquals("selected text", payload?.text) + } + + @Test + fun `desktop epub chrome tap script keeps click fallback for pointer-capable webviews`() { + assertTrue(DesktopEpubKeyNavigationScript.contains("var lastChromeTapNotifiedAt = 0;")) + assertTrue(DesktopEpubKeyNavigationScript.contains("function maybeNotifyChromeTapFromClick(event)")) + assertTrue(DesktopEpubKeyNavigationScript.contains("if (window.PointerEvent) {")) + assertTrue(DesktopEpubKeyNavigationScript.contains("maybeNotifyChromeTapFromClick(event);")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubPaginationTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubPaginationTest.kt new file mode 100644 index 0000000..6c22df2 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopEpubPaginationTest.kt @@ -0,0 +1,119 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.reader.ReaderPage +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.ReaderViewportSpec +import com.aryan.reader.shared.reader.layoutSignature +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopEpubPaginationTest { + @Test + fun `measured pagination is not ready until measured pages are applied`() { + val request = desktopPaginationRequest() + val currentPages = listOf(readerPage(text = "old page")) + val measuredPages = listOf(readerPage(text = "measured page")) + + assertFalse( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = currentPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `measured pagination is ready when current pages match measured pages`() { + val request = desktopPaginationRequest() + val measuredPages = listOf(readerPage(text = "measured page")) + + assertTrue( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = measuredPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `paginated display waits for completed measured pages`() { + assertFalse( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = false + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = true + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.VERTICAL, + measuredPagesApplied = false + ) + ) + } + + @Test + fun `measured chapter warm start replaces only that chapter and renumbers pages`() { + val currentPages = listOf( + readerPage(text = "chapter 0 page", chapterIndex = 0, pageIndex = 0), + readerPage(text = "chapter 1 old a", chapterIndex = 1, pageIndex = 1), + readerPage(text = "chapter 1 old b", chapterIndex = 1, pageIndex = 2), + readerPage(text = "chapter 2 page", chapterIndex = 2, pageIndex = 3) + ) + val measuredChapter = listOf( + readerPage(text = "chapter 1 measured", chapterIndex = 1, pageIndex = 1) + ) + + val pages = desktopPagesWithMeasuredChapter( + currentPages = currentPages, + chapterIndex = 1, + measuredChapterPages = measuredChapter + ) + + assertEquals(listOf(0, 1, 2), pages.map { it.pageIndex }) + assertEquals(listOf(0, 1, 2), pages.map { it.chapterIndex }) + assertEquals("chapter 1 measured", pages[1].text) + } + + private fun desktopPaginationRequest(): DesktopEpubPaginationRequest { + return DesktopEpubPaginationRequest( + bookId = "book", + chapterSignature = 1, + layoutSignature = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.SINGLE + ).layoutSignature(), + viewport = ReaderViewportSpec(widthPx = 1200, heightPx = 900), + density = DesktopEpubPaginationDensity(density = 1f, fontScale = 1f), + cacheGeneration = 0 + ) + } + + private fun readerPage( + text: String, + chapterIndex: Int = 0, + pageIndex: Int = 0 + ): ReaderPage { + return ReaderPage( + pageIndex = pageIndex, + chapterIndex = chapterIndex, + chapterTitle = "Chapter", + text = text, + startOffset = 0, + endOffset = text.length + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacementTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacementTest.kt new file mode 100644 index 0000000..cc89a9f --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFeatureNoticePlacementTest.kt @@ -0,0 +1,24 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFeatureNoticePlacementTest { + @Test + fun `main notice renders only in the main window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = null) + + assertTrue(placement.rendersInMainWindow()) + assertFalse(placement.rendersInReaderWindow("reader-1")) + } + + @Test + fun `reader notice renders only in the matching reader window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = "reader-1") + + assertFalse(placement.rendersInMainWindow()) + assertTrue(placement.rendersInReaderWindow("reader-1")) + assertFalse(placement.rendersInReaderWindow("reader-2")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabaseTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabaseTest.kt new file mode 100644 index 0000000..7c43edb --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabaseTest.kt @@ -0,0 +1,55 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.SharedLibrarySnapshot +import com.aryan.reader.shared.SharedLibrarySnapshotJson +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopLibraryDatabaseTest { + @Test + fun `save writes readable library and backup snapshots`() { + val databaseFile = Files.createTempDirectory("reader-library-db") + .resolve("library.json") + .toFile() + val database = DesktopLibraryDatabase(databaseFile) + val snapshot = SharedLibrarySnapshot( + recentFilesLimit = 37, + openTabIds = listOf("book-a"), + activeTabBookId = "book-a" + ) + + database.save(snapshot) + + val loaded = database.load() + assertEquals(37, loaded.recentFilesLimit) + assertEquals(listOf("book-a"), loaded.openTabIds) + assertEquals("book-a", loaded.activeTabBookId) + assertTrue(databaseFile.isFile) + assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile) + } + + @Test + fun `load falls back to backup when primary library is corrupt`() { + val databaseFile = Files.createTempDirectory("reader-library-db-corrupt") + .resolve("library.json") + .toFile() + val backupSnapshot = SharedLibrarySnapshot( + recentFilesLimit = 19, + openTabIds = listOf("backup-book"), + activeTabBookId = "backup-book" + ) + databaseFile.parentFile.mkdirs() + databaseFile.writeText("""{"books":[""") + databaseFile.parentFile + .resolve("library.json.bak") + .writeText(SharedLibrarySnapshotJson.encode(backupSnapshot)) + + val loaded = DesktopLibraryDatabase(databaseFile).load() + + assertEquals(19, loaded.recentFilesLimit) + assertEquals(listOf("backup-book"), loaded.openTabIds) + assertEquals("backup-book", loaded.activeTabBookId) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt index ade9952..70dd8ca 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSyncTest.kt @@ -11,8 +11,37 @@ import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class DesktopLocalFolderSyncTest { + @Test + fun `target folder sync imports files before desktop metadata extraction`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + val bookFile = File(root, "Notes.txt").apply { writeText("Notes") } + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState(), + shelfRefs = emptyList(), + targetFolder = root, + nowMillis = 3_000L, + extractMetadata = false + ) + + val syncedBook = result.state.rawLibraryBooks.single() + assertEquals("local_Notes.txt", syncedBook.id) + assertEquals(bookFile.absolutePath, syncedBook.path) + assertEquals(root.absolutePath, syncedBook.sourceFolder) + assertEquals(listOf(root.absolutePath), result.processedFolderUris) + assertEquals(1, result.state.syncedFolders.size) + assertEquals(1, result.stats.newBooks) + assertEquals(0, result.metadataStats.updatedBooks) + assertNull(syncedBook.coverImagePath) + } finally { + root.deleteRecursively() + } + } + @Test fun `metadata-only sync imports sidecar metadata without scanning physical files`() { val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() @@ -61,6 +90,40 @@ class DesktopLocalFolderSyncTest { assertEquals(0, result.stats.newBooks) assertEquals(0, result.stats.removedBooks) assertEquals(1, result.stats.remoteMetadataUpdates) + assertTrue(result.processedFolderUris.contains(root.absolutePath)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `disabled folder is not scanned or written`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + File(root, "Notes.txt").writeText("Notes") + val existingBook = BookItem( + id = "local_Existing.pdf", + path = File(root, "Existing.pdf").absolutePath, + type = FileType.PDF, + displayName = "Existing.pdf", + timestamp = 100L, + progressPercentage = 50f, + sourceFolder = root.absolutePath + ) + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(existingBook), + syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false)) + ), + shelfRefs = emptyList(), + nowMillis = 3_000L + ) + + assertEquals(listOf(existingBook), result.state.rawLibraryBooks) + assertTrue(result.processedFolderUris.isEmpty()) + assertEquals(0, result.stats.newBooks) + assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists()) } finally { root.deleteRecursively() } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPaidAiUsageTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPaidAiUsageTest.kt new file mode 100644 index 0000000..1dc17ff --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPaidAiUsageTest.kt @@ -0,0 +1,15 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPaidAiUsageTest { + @Test + fun `desktop paid AI usage applies an optimistic integer credit decrement`() { + assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0)) + assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null)) + assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationSidebarTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationSidebarTest.kt new file mode 100644 index 0000000..769f804 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationSidebarTest.kt @@ -0,0 +1,56 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopPdfNavigationSidebarTest { + @Test + fun `sidebar highlights exclude ink and text annotations`() { + val result = desktopPdfSidebarHighlights( + listOf( + annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L), + annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L), + annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L), + annotation( + id = "first-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 3L + ), + annotation( + id = "second-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 2L + ) + ) + ) + + assertEquals( + listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"), + result.map { it.id } + ) + assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT }) + } + + private fun annotation( + id: String, + pageIndex: Int, + kind: PdfAnnotationKind, + createdAt: Long + ): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = kind, + tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + createdAt = createdAt + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt index 18f472d..7c1333b 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfReflowTest.kt @@ -4,6 +4,7 @@ import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.FileType import com.aryan.reader.shared.SharedLibraryStateProjector import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.ui.toNonReaderLibraryOrganizationModel import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -85,6 +86,7 @@ class DesktopPdfReflowTest { assertEquals(listOf(source.id), projected.libraryBooks.map { it.id }) assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id }) assertTrue(projected.recentBooks.none { it.id == reflow.id }) + assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount) assertEquals(listOf(reflow.id), projected.openTabIds) assertEquals(reflow.id, projected.activeTabBookId) } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbingTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbingTest.kt new file mode 100644 index 0000000..dc2e0d1 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfScrubbingTest.kt @@ -0,0 +1,60 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.reader.ReaderPageSpreadMode +import com.aryan.reader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPdfScrubbingTest { + @Test + fun `scrub target clamps to valid page range`() { + val settings = ReaderSettings() + + assertEquals( + 0, + desktopPdfPageScrubTarget( + value = -10f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + assertEquals( + 5, + desktopPdfPageScrubTarget( + value = 99f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + } + + @Test + fun `paginated scrub target normalizes to spread start`() { + val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) + + assertEquals( + 2, + desktopPdfPageScrubTarget( + value = 3f, + pageCount = 8, + displayMode = PdfDisplayMode.PAGINATION, + settings = settings + ) + ) + } + + @Test + fun `scrub commit prefers preview before page state catches up`() { + assertEquals( + 7, + desktopPdfPageScrubCommitTarget( + previewPage = 7, + currentPage = 2, + pageCount = 10 + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarsTest.kt new file mode 100644 index 0000000..ff1e73f --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfSidecarsTest.kt @@ -0,0 +1,16 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class DesktopPdfSidecarsTest { + @Test + fun `pdf sidecar keys avoid String hashCode collisions`() { + val first = desktopPdfDocumentKey("C:/Books/Aa.pdf") + val second = desktopPdfDocumentKey("C:/Books/BB.pdf") + + assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode()) + assertNotEquals(first, second) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfTextHighlightStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfTextHighlightStateTest.kt new file mode 100644 index 0000000..e2a4ee7 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfTextHighlightStateTest.kt @@ -0,0 +1,69 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfReaderAction +import com.aryan.reader.shared.pdf.SharedPdfReaderState +import com.aryan.reader.shared.pdf.reduce +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopPdfTextHighlightStateTest { + @Test + fun `text selection highlight keeps chosen text selection mode after creation`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + + val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation) + + assertEquals(listOf(annotation), next.annotations) + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing selected text highlight sheet keeps chosen text selection mode`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing non text highlight annotation keeps text selection mode unchanged`() { + val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null) + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertNull(next.selectedAnnotationId) + } + + private fun textSelectionHighlight(): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = "highlight-1", + pageIndex = 0, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + rangeStartIndex = 1, + rangeEndIndex = 12, + createdAt = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt index fd47373..a386b68 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPdfThemeTest.kt @@ -9,9 +9,10 @@ import kotlin.test.assertEquals class DesktopPdfThemeTest { @Test - fun `desktop pdf defaults to vertical display mode`() { - assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode) + fun `desktop pdf defaults to paginated display mode`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap) + assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap) } @Test @@ -49,4 +50,35 @@ class DesktopPdfThemeTest { ) ) } + + @Test + fun `pagination viewport uses app theme color outside pages`() { + val pageBackground = Color.Black + val appBackground = Color(0xFFE2E2E2) + + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = false + ) + ) + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = true + ) + ) + } + + @Test + fun `spread page gap follows pdf page gap visibility setting`() { + assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true)) + assertEquals(0.dp, desktopPdfSpreadPageGapDp(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 index 170cedc..aee2fc3 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopPlatformPathsTest.kt @@ -10,7 +10,6 @@ class DesktopPlatformPathsTest { 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) @@ -22,7 +21,6 @@ class DesktopPlatformPathsTest { 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) diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt index 68fcdae..abd4154 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderDefaultsTest.kt @@ -5,13 +5,17 @@ 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.PdfDisplayMode import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.SharedLibrarySnapshot import com.aryan.reader.shared.pdf.PdfZoomSpec +import com.aryan.reader.shared.reader.ReaderPageSpreadMode 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.assertFalse import kotlin.test.assertTrue class DesktopReaderDefaultsTest { @@ -42,6 +46,96 @@ class DesktopReaderDefaultsTest { assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) } + @Test + fun `desktop library defaults migrate untouched reader defaults to two page pagination`() { + val migrated = SharedLibrarySnapshot().withDesktopDefaults() + + assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion) + assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode) + assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode) + assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId) + } + + @Test + fun `desktop reader settings engines are separated by shared reader surface`() { + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine()) + } + + @Test + fun `desktop engine settings update only matching reader family books`() { + val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED) + val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED) + val books = listOf( + bookItem("epub"), + bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"), + bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf") + ) + + val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings) + assertEquals(textSettings, withTextDefaults[0].readerSettings) + assertEquals(textSettings, withTextDefaults[1].readerSettings) + assertEquals(null, withTextDefaults[2].readerSettings) + + val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings) + assertEquals(textSettings, withPdfDefaults[0].readerSettings) + assertEquals(textSettings, withPdfDefaults[1].readerSettings) + assertEquals(pdfSettings, withPdfDefaults[2].readerSettings) + } + + @Test + fun `desktop pdf display mode is carried by pdf reader settings`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode()) + assertEquals( + PdfDisplayMode.VERTICAL_SCROLL, + ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode() + ) + } + + @Test + fun `desktop pdf initial page is normalized before paginated spread display`() { + val spreadSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals( + 2, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) + ) + ) + } + @Test fun `desktop pdf zoom allows deeper page magnification`() { val sharedDefaultMax = PdfZoomSpec().max @@ -65,6 +159,26 @@ class DesktopReaderDefaultsTest { assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) } + @Test + fun `desktop pdf page navigation commits pending zoom preview position`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 80f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0 + ) + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = preview, + currentHorizontalScroll = 40, + currentVerticalScroll = 20 + ) ?: error("Expected navigation zoom snapshot") + + assertEquals(2f, snapshot.zoom) + assertEquals(180, snapshot.horizontalScroll) + assertEquals(120, snapshot.verticalScroll) + } + @Test fun `desktop paginated pdf page changes avoid high resolution first render`() { assertEquals( @@ -93,6 +207,247 @@ class DesktopReaderDefaultsTest { ) } + @Test + fun `desktop pdf only displays renders for the requested page`() { + assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0)) + } + + @Test + fun `desktop pdf render scale rerenders only for missing or lower quality renders`() { + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN)) + } + + @Test + fun `desktop pdf spread zoom anchors to page under cursor`() { + val visiblePages = listOf(199, 200) + val pageRoots = mapOf( + 199 to Offset(424f, 30f), + 200 to Offset(972f, 30f) + ) + val pageSizes = mapOf( + 199 to IntSize(525, 693), + 200 to IntSize(525, 693) + ) + + assertEquals( + 200, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(1048.75f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(500f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = null, + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + val fittedSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(667, 881), + 200 to IntSize(667, 881) + ), + horizontalScroll = 0, + verticalScroll = 112, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected fitted spread prediction") + assertEquals(0, fittedSpread.maxHorizontalScroll) + assertEquals(0, fittedSpread.maxVerticalScroll) + assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f) + assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f) + assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f) + + val scrollableSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(1371, 1810), + 200 to IntSize(1371, 1810) + ), + horizontalScroll = 696, + verticalScroll = 575, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected scrollable spread prediction") + assertEquals(905, scrollableSpread.maxHorizontalScroll) + assertEquals(879, scrollableSpread.maxVerticalScroll) + assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f) + assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f) + assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f) + } + + @Test + fun `desktop pdf zoom preview bridges committed anchored zoom`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 100f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0, + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero + ) + + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f)) + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f)) + assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f)) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + null, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 1f + ) + ) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100)) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40)) + assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40)) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + ) + ) + assertEquals( + Offset(-50f, -25f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 50, + currentVerticalScroll = 0, + maxVerticalScroll = 25 + ) + ) + ) + val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets( + preview = preview.copy( + commitTargetHorizontalScroll = 300, + commitTargetVerticalScroll = 300 + ), + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + assertEquals(300, pendingCommitBounds.maxHorizontalScroll) + assertEquals(300, pendingCommitBounds.maxVerticalScroll) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = pendingCommitBounds + ) + ) + val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(1216, 1605), + horizontalScroll = 0, + verticalScroll = 0, + paddingPx = 30f + ) ?: error("Expected fitting page prediction") + assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset) + assertEquals(0, fittingPagePrediction.maxHorizontalScroll) + assertEquals(674, fittingPagePrediction.maxVerticalScroll) + + val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(2498, 3298), + horizontalScroll = 409, + verticalScroll = 1122, + paddingPx = 30f + ) ?: error("Expected oversized page prediction") + assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset) + assertEquals(638, oversizedPagePrediction.maxHorizontalScroll) + assertEquals(2367, oversizedPagePrediction.maxVerticalScroll) + } + @Test fun `desktop pdf anchored zoom keeps cursor content stable`() { assertEquals( diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderKeyCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderKeyCommandsTest.kt new file mode 100644 index 0000000..e12afbd --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderKeyCommandsTest.kt @@ -0,0 +1,174 @@ +package com.aryan.reader.desktop + +import java.awt.Canvas +import java.awt.event.KeyEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopReaderKeyCommandsTest { + + @Test + fun `ctrl f opens epub reader search`() { + assertEquals( + DesktopReaderKeyNavigation.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopReaderKeyNavigationOrNull(fullscreen = false) + ) + } + + @Test + fun `epub right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopReaderKeyNavigation.PREVIOUS, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + } + + @Test + fun `ctrl f opens pdf reader search while reading`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false) + ) + } + + @Test + fun `ctrl f opens pdf reader search while text editing`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf text editing keeps unmodified arrows for the editor`() { + assertNull( + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopPdfKeyCommand.PREVIOUS_PAGE, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + } + + @Test + fun `reader side panels can opt into global key dispatch without enabling popups`() { + assertEquals( + DesktopReaderModalWindowKind.PANEL, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft", + windowTitle = "Reader Navigation" + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.PANEL, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.POPUP, + allowChromeModalWindows = true, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = true + ) + ) + } + + @Test + fun `reader chrome and owner window dispatch remain separately gated`() { + assertEquals( + DesktopReaderModalWindowKind.CHROME, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop", + windowTitle = "Reader Chrome Top" + ) + ) + assertEquals( + DesktopReaderModalWindowKind.POPUP, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}Popup", + windowTitle = "Reader Popup" + ) + ) + assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme")) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = null, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.CHROME, + allowChromeModalWindows = true, + allowPanelModalWindows = false, + dispatchWhenOwnerWindowActive = false + ) + ) + } + + private fun awtKeyEvent( + keyCode: Int, + modifiers: Int, + keyChar: Char + ): KeyEvent { + return KeyEvent( + Canvas(), + KeyEvent.KEY_PRESSED, + 0L, + modifiers, + keyCode, + keyChar + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderTypographyTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderTypographyTest.kt new file mode 100644 index 0000000..5103149 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderTypographyTest.kt @@ -0,0 +1,69 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.unit.sp +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticParagraph +import com.aryan.reader.shared.reader.ReaderPage +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopReaderTypographyTest { + + @Test + fun `same page layout includes semantic styling`() { + val plain = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + val styled = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertFalse(listOf(plain).samePageLayoutAs(listOf(styled))) + } + + @Test + fun `same page layout still matches identical semantic pages`() { + val page = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy()))) + } + + private fun pageWith(block: SemanticParagraph): ReaderPage { + return ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = block.text, + startOffset = 0, + endOffset = block.text.length, + semanticBlocks = listOf(block) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt index 1aa6792..4d0ab94 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopReaderWindowStateTest.kt @@ -1,14 +1,23 @@ package com.aryan.reader.desktop +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.ui.SharedAppTab import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopReaderWindowStateTest { + @Test + fun `desktop starts on library instead of home`() { + assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab) + } + @Test fun `opening a new reader creates a window`() { val opening = readerOpening("book-1", requestId = 1) @@ -59,6 +68,67 @@ class DesktopReaderWindowStateTest { assertEquals(2L, decision.windows.single().focusRequestId) } + @Test + fun `reader window uses persisted size instead of hardcoded fallback`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 1340f, + heightDp = 840f + ) + + val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize) + + assertEquals(1340.dp, size.width) + assertEquals(840.dp, size.height) + } + + @Test + fun `reader window defaults preserve previous detached reader size`() { + assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width) + assertEquals(760.dp, DesktopReaderWindowDefaultSize.height) + } + + @Test + fun `reader window persistence ignores fullscreen snapshots`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FULLSCREEN, + widthDp = 1920f, + heightDp = 1080f + ) + + assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement()) + assertNull(snapshot.toPersistableReaderWindowSnapshot()) + } + + @Test + fun `native webview text reader resets surface when switching from vertical to paginated`() { + assertTrue( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = true + ) + ) + } + + @Test + fun `text reader surface reset is limited to native webview vertical to paginated switches`() { + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.PAGINATED, + currentMode = ReaderReadingMode.VERTICAL, + usesNativeWebView = true + ) + ) + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = false + ) + ) + } + private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening { return DesktopReaderOpening( requestId = requestId, diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt index 23d59ad..a605f49 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStartupTest.kt @@ -1,10 +1,13 @@ package com.aryan.reader.desktop -import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.SharedReaderScreenState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import java.io.File +import java.nio.file.Files class DesktopStartupTest { @Test @@ -25,34 +28,174 @@ class DesktopStartupTest { } @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)) + fun `desktop epub webview uses native browser backends without bundled runtime`() { + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux)) + assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows)) + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos)) + assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other)) + + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos)) + assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other)) } @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) - ) + fun `native webviews can render without bundled runtime state`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux)) + assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other)) + } + + @Test + fun `native webview unavailable messages point to the platform runtime`() { + val windowsMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, + detail = "missing runtime" ) - assertFalse( - shouldStartDesktopWebViewRuntime( - requested = true, - state = DesktopWebViewRuntimeState(restartRequired = true) - ) + val linuxMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WEBKIT, + detail = "missing library" ) - assertFalse( - shouldStartDesktopWebViewRuntime( - requested = true, - state = DesktopWebViewRuntimeState(errorMessage = "missing bundle") + + assertTrue(windowsMessage.contains("WebView2 Runtime")) + assertTrue(windowsMessage.contains("missing runtime")) + assertTrue(linuxMessage.contains("WebKitGTK")) + assertTrue(linuxMessage.contains("Linux distribution packages")) + assertTrue(linuxMessage.contains("missing library")) + } + + @Test + fun `compose interop blending stays off by default for native swt webviews`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertNull(composeInteropBlendingDefault(windows)) + assertNull(composeInteropBlendingDefault(linux)) + assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other)) + } + + @Test + fun `silent startup folder sync does not surface missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = false + ) + + assertNull(completed.bannerMessage) + } + + @Test + fun `manual folder sync still surfaces missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = true + ) + + assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message) + assertTrue(completed.bannerMessage?.isError == true) + } + + @Test + fun `desktop account profile store restores cached profile for matching user`() { + val directory = Files.createTempDirectory("episteme-account-profile-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L)) + + assertEquals( + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L), + store.load("user-1") ) + assertNull(store.load("user-2")) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile freshness uses fetched timestamp`() { + val now = 10_000L + val ttl = 1_000L + + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl)) + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl)) + } + + @Test + fun `desktop account profile repository ignores stale startup cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + val now = DesktopAccountProfileCacheTtlMillis + 10_000L + val freshProfile = DesktopAccountProfile( + isProUser = true, + credits = 42, + fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L + ) + + repository.saveFetchedProfile("user-1", freshProfile) + assertEquals(freshProfile, repository.cachedProfile("user-1", now)) + + repository.saveFetchedProfile( + "user-1", + freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L) + ) + assertNull(repository.cachedProfile("user-1", now)) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile repository clear removes sign out cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + + repository.saveFetchedProfile( + "user-1", + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L) + ) + repository.clearCachedProfiles() + + assertNull(repository.cachedProfile("user-1", 10_001L)) + assertNull(store.load("user-1")) + } finally { + directory.deleteRecursively() + } + } + + private fun testDesktopCloudConfig(): DesktopCloudConfig { + return DesktopCloudConfig( + aiWorkerUrl = "", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "reader-test", + googleOAuthClientId = "", + googleOAuthClientSecret = "" ) } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt index 5fb6234..8ba9ae7 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopStringResourcesTest.kt @@ -72,6 +72,24 @@ class DesktopStringResourcesTest { assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one")) } + @Test + fun loadsAndroidToolbarTooltipDescriptionsForDesktop() { + val resources = DesktopAndroidStringResources.load( + locale = Locale.ENGLISH, + classLoader = Thread.currentThread().contextClassLoader + ?: DesktopStringResourcesTest::class.java.classLoader + ) + + assertEquals( + "Exit search and go back to the reader", + resources.stringOrNull("tooltip_close_search_desc") + ) + assertEquals( + "Jump to the next search match in the document", + resources.stringOrNull("tooltip_next_result_desc") + ) + } + @Test fun choosesDesktopPluralQuantityForSupportedLanguages() { val slavicQuantities = setOf("one", "few", "many", "other") diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopTtsLogTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopTtsLogTest.kt new file mode 100644 index 0000000..7180b83 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopTtsLogTest.kt @@ -0,0 +1,18 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopTtsLogTest { + @Test + fun `desktop tts preview redacts key and token query values`() { + val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret" + .desktopTtsPreview(300) + + assertFalse(preview.contains("gemini_secret")) + assertFalse(preview.contains("firebase_secret")) + assertTrue(preview.contains("key=")) + assertTrue(preview.contains("token=")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWebView2LayoutTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWebView2LayoutTest.kt new file mode 100644 index 0000000..30570ef --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWebView2LayoutTest.kt @@ -0,0 +1,44 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopWebView2LayoutTest { + @Test + fun `webview2 host bounds match the awt canvas logical size`() { + val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900) + + assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds) + } + + @Test + fun `webview2 host bounds are unavailable before the canvas has size`() { + assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900)) + assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0)) + } + + @Test + fun `webview2 awt canvas is not retired while host window is closing`() { + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = true, + hostWindowDisplayable = true + ) + ) + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = false + ) + ) + assertTrue( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = true + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt index 24a7ba6..c87903d 100644 --- a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopWindowStateStoreTest.kt @@ -34,4 +34,10 @@ class DesktopWindowStateStoreTest { assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp) assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp) } + + @Test + fun `reader window state uses a separate config file`() { + assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name) + assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name) + } } diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/LinuxSecretToolCodecTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/LinuxSecretToolCodecTest.kt new file mode 100644 index 0000000..91974ef --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/LinuxSecretToolCodecTest.kt @@ -0,0 +1,55 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LinuxSecretToolCodecTest { + @Test + fun `secret tool codec stores looks up and clears secrets by key`() { + val runner = FakeSecretCommandRunner() + val codec = LinuxSecretToolCodec(runner) + + assertTrue(codec.isAvailable) + val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh") + + assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference)) + codec.delete("firebaseRefreshTokenProtected") + assertTrue(runner.storedSecrets.isEmpty()) + } + + private class FakeSecretCommandRunner : DesktopSecretCommandRunner { + val storedSecrets = linkedMapOf() + + override fun isExecutableAvailable(command: String): Boolean { + return command == "secret-tool" + } + + override fun run( + command: List, + input: String?, + timeoutMillis: Long + ): DesktopSecretCommandResult { + return when (command.getOrNull(1)) { + "--help" -> DesktopSecretCommandResult(0, "usage", "") + "store" -> { + storedSecrets[command.last()] = input.orEmpty() + DesktopSecretCommandResult(0, "", "") + } + "lookup" -> { + val secret = storedSecrets[command.last()] + if (secret == null) { + DesktopSecretCommandResult(1, "", "not found") + } else { + DesktopSecretCommandResult(0, "$secret\n", "") + } + } + "clear" -> { + storedSecrets.remove(command.last()) + DesktopSecretCommandResult(0, "", "") + } + else -> DesktopSecretCommandResult(1, "", "unexpected command") + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c991cf3..3dd99a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidxTestRunner = "1.6.2" material3WindowSizeClassAndroid = "1.3.2" credentials = "1.5.0" composeMultiplatform = "1.8.2" +ksp = "2.2.10-2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,7 +62,8 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" } +kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } # Add plugins required by pdfiumandroid diff --git a/settings.gradle.kts b/settings.gradle.kts index 37f309d..a27ab25 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,7 +18,7 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://jogamp.org/deployment/maven") - maven("https.jitpack.io") + maven("https://jitpack.io") } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d2a7964..c044eb4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose.multiplatform) - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kover) } @@ -28,7 +28,6 @@ kotlin { commonMain.dependencies { implementation(compose.foundation) implementation(compose.material3) - implementation(compose.materialIconsExtended) implementation(compose.ui) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") @@ -48,4 +47,8 @@ android { defaultConfig { minSdk = 26 } + + buildFeatures { + buildConfig = true + } } 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 index b3a15a9..269b0df 100644 --- 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 @@ -1,5 +1,17 @@ package com.aryan.reader.shared.reader -internal actual val SharedReaderDiagnosticsEnabled: Boolean = false +import android.util.Log +import com.aryan.reader.shared.BuildConfig -internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false +internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (!BuildConfig.DEBUG) return false + return tag == SharedEpubCutoffDiagnosticsTag || + runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) +} + +internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { + if (!BuildConfig.DEBUG) return + Log.d(tag, message) +} diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt new file mode 100644 index 0000000..2624e69 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt @@ -0,0 +1,12 @@ +package androidx.compose.material.icons + +object Icons { + object Filled + val Default: Filled get() = Filled + + object Outlined + + object AutoMirrored { + object Filled + } +} diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt new file mode 100644 index 0000000..5398bb8 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt @@ -0,0 +1,261 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.automirrored.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.AutoMirrored.Filled.ArrowBack: ImageVector + get() = EpistemeAutoMirroredFilledIcons.arrowBack + +val Icons.AutoMirrored.Filled.ArrowForward: ImageVector + get() = EpistemeAutoMirroredFilledIcons.arrowForward + +val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector + get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight + +val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector + get() = EpistemeAutoMirroredFilledIcons.libraryBooks + +val Icons.AutoMirrored.Filled.List: ImageVector + get() = EpistemeAutoMirroredFilledIcons.list + +val Icons.AutoMirrored.Filled.MenuBook: ImageVector + get() = EpistemeAutoMirroredFilledIcons.menuBook + +val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector + get() = EpistemeAutoMirroredFilledIcons.navigateBefore + +val Icons.AutoMirrored.Filled.NavigateNext: ImageVector + get() = EpistemeAutoMirroredFilledIcons.navigateNext + +val Icons.AutoMirrored.Filled.OpenInNew: ImageVector + get() = EpistemeAutoMirroredFilledIcons.openInNew + +val Icons.AutoMirrored.Filled.Redo: ImageVector + get() = EpistemeAutoMirroredFilledIcons.redo + +val Icons.AutoMirrored.Filled.Sort: ImageVector + get() = EpistemeAutoMirroredFilledIcons.sort + +val Icons.AutoMirrored.Filled.Undo: ImageVector + get() = EpistemeAutoMirroredFilledIcons.undo + +val Icons.AutoMirrored.Filled.VolumeUp: ImageVector + get() = EpistemeAutoMirroredFilledIcons.volumeUp + +private object EpistemeAutoMirroredFilledIcons { + val arrowBack: ImageVector by lazy { + materialIcon( + name = "ArrowBack", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z""" + ) + ) + } + + val arrowForward: ImageVector by lazy { + materialIcon( + name = "ArrowForward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z""" + ) + ) + } + + val keyboardArrowRight: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val libraryBooks: ImageVector by lazy { + materialIcon( + name = "LibraryBooks", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z""" + ) + ) + } + + val list: ImageVector by lazy { + materialIcon( + name = "List", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z""" + ) + ) + } + + val menuBook: ImageVector by lazy { + materialIcon( + name = "MenuBook", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,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,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z""" + ) + ) + } + + val navigateBefore: ImageVector by lazy { + materialIcon( + name = "NavigateBefore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val navigateNext: ImageVector by lazy { + materialIcon( + name = "NavigateNext", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val openInNew: ImageVector by lazy { + materialIcon( + name = "OpenInNew", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z""" + ) + ) + } + + val redo: ImageVector by lazy { + materialIcon( + name = "Redo", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z""" + ) + ) + } + + val sort: ImageVector by lazy { + materialIcon( + name = "Sort", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val undo: ImageVector by lazy { + materialIcon( + name = "Undo", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z""" + ) + ) + } + + val volumeUp: ImageVector by lazy { + materialIcon( + name = "VolumeUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = true, + paths = listOf( + """M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt new file mode 100644 index 0000000..8f5fe46 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt @@ -0,0 +1,1519 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.Filled.AccountCircle: ImageVector + get() = EpistemeFilledIcons.accountCircle + +val Icons.Filled.Add: ImageVector + get() = EpistemeFilledIcons.add + +val Icons.Filled.ArrowDownward: ImageVector + get() = EpistemeFilledIcons.arrowDownward + +val Icons.Filled.ArrowDropDown: ImageVector + get() = EpistemeFilledIcons.arrowDropDown + +val Icons.Filled.ArrowDropUp: ImageVector + get() = EpistemeFilledIcons.arrowDropUp + +val Icons.Filled.ArrowUpward: ImageVector + get() = EpistemeFilledIcons.arrowUpward + +val Icons.Filled.Book: ImageVector + get() = EpistemeFilledIcons.book + +val Icons.Filled.Bookmark: ImageVector + get() = EpistemeFilledIcons.bookmark + +val Icons.Filled.BookmarkBorder: ImageVector + get() = EpistemeFilledIcons.bookmarkBorder + +val Icons.Filled.Brush: ImageVector + get() = EpistemeFilledIcons.brush + +val Icons.Filled.BugReport: ImageVector + get() = EpistemeFilledIcons.bugReport + +val Icons.Filled.Check: ImageVector + get() = EpistemeFilledIcons.check + +val Icons.Filled.ChevronLeft: ImageVector + get() = EpistemeFilledIcons.chevronLeft + +val Icons.Filled.ChevronRight: ImageVector + get() = EpistemeFilledIcons.chevronRight + +val Icons.Filled.Close: ImageVector + get() = EpistemeFilledIcons.close + +val Icons.Filled.Cloud: ImageVector + get() = EpistemeFilledIcons.cloud + +val Icons.Filled.CloudDownload: ImageVector + get() = EpistemeFilledIcons.cloudDownload + +val Icons.Filled.Code: ImageVector + get() = EpistemeFilledIcons.code + +val Icons.Filled.ContentCopy: ImageVector + get() = EpistemeFilledIcons.contentCopy + +val Icons.Filled.CopyAll: ImageVector + get() = EpistemeFilledIcons.copyAll + +val Icons.Filled.CreateNewFolder: ImageVector + get() = EpistemeFilledIcons.createNewFolder + +val Icons.Filled.Delete: ImageVector + get() = EpistemeFilledIcons.delete + +val Icons.Filled.Description: ImageVector + get() = EpistemeFilledIcons.description + +val Icons.Filled.DoNotTouch: ImageVector + get() = EpistemeFilledIcons.doNotTouch + +val Icons.Filled.Download: ImageVector + get() = EpistemeFilledIcons.download + +val Icons.Filled.Edit: ImageVector + get() = EpistemeFilledIcons.edit + +val Icons.Filled.Email: ImageVector + get() = EpistemeFilledIcons.email + +val Icons.Filled.ExpandLess: ImageVector + get() = EpistemeFilledIcons.expandLess + +val Icons.Filled.ExpandMore: ImageVector + get() = EpistemeFilledIcons.expandMore + +val Icons.Filled.Favorite: ImageVector + get() = EpistemeFilledIcons.favorite + +val Icons.Filled.Feedback: ImageVector + get() = EpistemeFilledIcons.feedback + +val Icons.Filled.FileOpen: ImageVector + get() = EpistemeFilledIcons.fileOpen + +val Icons.Filled.FilterList: ImageVector + get() = EpistemeFilledIcons.filterList + +val Icons.Filled.Folder: ImageVector + get() = EpistemeFilledIcons.folder + +val Icons.Filled.FolderSpecial: ImageVector + get() = EpistemeFilledIcons.folderSpecial + +val Icons.Filled.FormatListNumbered: ImageVector + get() = EpistemeFilledIcons.formatListNumbered + +val Icons.Filled.Fullscreen: ImageVector + get() = EpistemeFilledIcons.fullscreen + +val Icons.Filled.FullscreenExit: ImageVector + get() = EpistemeFilledIcons.fullscreenExit + +val Icons.Filled.Gavel: ImageVector + get() = EpistemeFilledIcons.gavel + +val Icons.Filled.GraphicEq: ImageVector + get() = EpistemeFilledIcons.graphicEq + +val Icons.Filled.ImportExport: ImageVector + get() = EpistemeFilledIcons.importExport + +val Icons.Filled.Info: ImageVector + get() = EpistemeFilledIcons.info + +val Icons.Filled.KeyboardArrowDown: ImageVector + get() = EpistemeFilledIcons.keyboardArrowDown + +val Icons.Filled.KeyboardArrowLeft: ImageVector + get() = EpistemeFilledIcons.keyboardArrowLeft + +val Icons.Filled.KeyboardArrowRight: ImageVector + get() = EpistemeFilledIcons.keyboardArrowRight + +val Icons.Filled.KeyboardArrowUp: ImageVector + get() = EpistemeFilledIcons.keyboardArrowUp + +val Icons.Filled.Lock: ImageVector + get() = EpistemeFilledIcons.lock + +val Icons.Filled.LockOpen: ImageVector + get() = EpistemeFilledIcons.lockOpen + +val Icons.Filled.Menu: ImageVector + get() = EpistemeFilledIcons.menu + +val Icons.Filled.MoreVert: ImageVector + get() = EpistemeFilledIcons.moreVert + +val Icons.Filled.MyLocation: ImageVector + get() = EpistemeFilledIcons.myLocation + +val Icons.Filled.OpenInNew: ImageVector + get() = EpistemeFilledIcons.openInNew + +val Icons.Filled.Palette: ImageVector + get() = EpistemeFilledIcons.palette + +val Icons.Filled.Pause: ImageVector + get() = EpistemeFilledIcons.pause + +val Icons.Filled.PhoneAndroid: ImageVector + get() = EpistemeFilledIcons.phoneAndroid + +val Icons.Filled.PlayArrow: ImageVector + get() = EpistemeFilledIcons.playArrow + +val Icons.Filled.PlayCircle: ImageVector + get() = EpistemeFilledIcons.playCircle + +val Icons.Filled.Policy: ImageVector + get() = EpistemeFilledIcons.policy + +val Icons.Filled.Print: ImageVector + get() = EpistemeFilledIcons.print + +val Icons.Filled.Psychology: ImageVector + get() = EpistemeFilledIcons.psychology + +val Icons.Filled.PushPin: ImageVector + get() = EpistemeFilledIcons.pushPin + +val Icons.Filled.Refresh: ImageVector + get() = EpistemeFilledIcons.refresh + +val Icons.Filled.Remove: ImageVector + get() = EpistemeFilledIcons.remove + +val Icons.Filled.Restore: ImageVector + get() = EpistemeFilledIcons.restore + +val Icons.Filled.Save: ImageVector + get() = EpistemeFilledIcons.save + +val Icons.Filled.ScreenRotation: ImageVector + get() = EpistemeFilledIcons.screenRotation + +val Icons.Filled.Search: ImageVector + get() = EpistemeFilledIcons.search + +val Icons.Filled.SelectAll: ImageVector + get() = EpistemeFilledIcons.selectAll + +val Icons.Filled.Settings: ImageVector + get() = EpistemeFilledIcons.settings + +val Icons.Filled.Share: ImageVector + get() = EpistemeFilledIcons.share + +val Icons.Filled.SkipNext: ImageVector + get() = EpistemeFilledIcons.skipNext + +val Icons.Filled.SkipPrevious: ImageVector + get() = EpistemeFilledIcons.skipPrevious + +val Icons.Filled.Smartphone: ImageVector + get() = EpistemeFilledIcons.smartphone + +val Icons.Filled.Star: ImageVector + get() = EpistemeFilledIcons.star + +val Icons.Filled.Stop: ImageVector + get() = EpistemeFilledIcons.stop + +val Icons.Filled.SwapHoriz: ImageVector + get() = EpistemeFilledIcons.swapHoriz + +val Icons.Filled.Sync: ImageVector + get() = EpistemeFilledIcons.sync + +val Icons.Filled.Tag: ImageVector + get() = EpistemeFilledIcons.tag + +val Icons.Filled.TextFields: ImageVector + get() = EpistemeFilledIcons.textFields + +val Icons.Filled.TouchApp: ImageVector + get() = EpistemeFilledIcons.touchApp + +val Icons.Filled.Translate: ImageVector + get() = EpistemeFilledIcons.translate + +val Icons.Filled.Tune: ImageVector + get() = EpistemeFilledIcons.tune + +val Icons.Filled.Verified: ImageVector + get() = EpistemeFilledIcons.verified + +val Icons.Filled.VerifiedUser: ImageVector + get() = EpistemeFilledIcons.verifiedUser + +val Icons.Filled.Visibility: ImageVector + get() = EpistemeFilledIcons.visibility + +val Icons.Filled.VisibilityOff: ImageVector + get() = EpistemeFilledIcons.visibilityOff + +val Icons.Filled.ZoomOut: ImageVector + get() = EpistemeFilledIcons.zoomOut + +private object EpistemeFilledIcons { + val accountCircle: ImageVector by lazy { + materialIcon( + name = "AccountCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z""" + ) + ) + } + + val add: ImageVector by lazy { + materialIcon( + name = "Add", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z""" + ) + ) + } + + val arrowDownward: ImageVector by lazy { + materialIcon( + name = "ArrowDownward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,160L440,647L216,423L160,480L480,800L800,480L744,423L520,647L520,160L440,160Z""" + ) + ) + } + + val arrowDropDown: ImageVector by lazy { + materialIcon( + name = "ArrowDropDown", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600L280,400L680,400L480,600Z""" + ) + ) + } + + val arrowDropUp: ImageVector by lazy { + materialIcon( + name = "ArrowDropUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,560L480,360L680,560L280,560Z""" + ) + ) + } + + val arrowUpward: ImageVector by lazy { + materialIcon( + name = "ArrowUpward", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z""" + ) + ) + } + + val book: ImageVector by lazy { + materialIcon( + name = "Book", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M300,880Q242,880 201,839Q160,798 160,740L160,220Q160,162 201,121Q242,80 300,80L800,80L800,680Q775,680 757.5,697.5Q740,715 740,740Q740,765 757.5,782.5Q775,800 800,800L800,880L300,880ZM240,613Q254,606 269,603Q284,600 300,600L320,600L320,160L300,160Q275,160 257.5,177.5Q240,195 240,220L240,613ZM400,600L720,600L720,160L400,160L400,600ZM240,613Q240,613 240,613Q240,613 240,613L240,613L240,160L240,160Q240,160 240,160Q240,160 240,160L240,613ZM300,800L673,800Q667,786 663.5,771.5Q660,757 660,740Q660,724 663,709Q666,694 673,680L300,680Q274,680 257,697.5Q240,715 240,740Q240,766 257,783Q274,800 300,800Z""" + ) + ) + } + + val bookmark: ImageVector by lazy { + materialIcon( + name = "Bookmark", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,840L480,720L200,840ZM280,718L480,632L680,718L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,718ZM280,200L280,200Q280,200 280,200Q280,200 280,200L680,200Q680,200 680,200Q680,200 680,200L680,200L480,200L280,200Z""" + ) + ) + } + + val bookmarkBorder: ImageVector by lazy { + materialIcon( + name = "BookmarkBorder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,840L480,720L200,840ZM280,718L480,632L680,718L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,718ZM280,200L280,200Q280,200 280,200Q280,200 280,200L680,200Q680,200 680,200Q680,200 680,200L680,200L480,200L280,200Z""" + ) + ) + } + + val brush: ImageVector by lazy { + materialIcon( + name = "Brush", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,840Q195,840 151,818Q107,796 80,760Q106,760 133,739.5Q160,719 160,680Q160,630 195,595Q230,560 280,560Q330,560 365,595Q400,630 400,680Q400,746 353,793Q306,840 240,840ZM240,760Q273,760 296.5,736.5Q320,713 320,680Q320,663 308.5,651.5Q297,640 280,640Q263,640 251.5,651.5Q240,663 240,680Q240,703 234.5,722Q229,741 220,758Q225,760 230,760Q235,760 240,760ZM470,600L360,490L718,132Q729,121 745.5,120.5Q762,120 774,132L828,186Q840,198 840,214Q840,230 828,242L470,600ZM280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Q280,680 280,680Z""" + ) + ) + } + + val bugReport: ImageVector by lazy { + materialIcon( + name = "BugReport", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z""" + ) + ) + } + + val check: ImageVector by lazy { + materialIcon( + name = "Check", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z""" + ) + ) + } + + val chevronLeft: ImageVector by lazy { + materialIcon( + name = "ChevronLeft", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val chevronRight: ImageVector by lazy { + materialIcon( + name = "ChevronRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val close: ImageVector by lazy { + materialIcon( + name = "Close", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z""" + ) + ) + } + + val cloud: ImageVector by lazy { + materialIcon( + name = "Cloud", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val cloudDownload: ImageVector by lazy { + materialIcon( + name = "CloudDownload", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q227,294 295,229Q363,164 440,164Q473,164 496.5,187.5Q520,211 520,244L520,486L584,424L640,480L480,640L320,480L376,424L440,486L440,244Q364,258 322,317.5Q280,377 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,392 658,350.5Q636,309 600,280L600,187Q674,222 717,290.5Q760,359 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442L480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442Q480,442 480,442L480,442L480,442Q480,442 480,442Q480,442 480,442Z""" + ) + ) + } + + val code: ImageVector by lazy { + materialIcon( + name = "Code", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,720L80,480L320,240L377,297L193,481L376,664L320,720ZM640,720L583,663L767,479L584,296L640,240L880,480L640,720Z""" + ) + ) + } + + val contentCopy: ImageVector by lazy { + materialIcon( + name = "ContentCopy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """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 copyAll: ImageVector by lazy { + materialIcon( + name = "CopyAll", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,740L120,660L200,660L200,740L120,740ZM120,600L120,520L200,520L200,600L120,600ZM120,460L120,380L200,380L200,460L120,460ZM260,880L260,800L340,800L340,880L260,880ZM360,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,640ZM400,880L400,800L480,800L480,880L400,880ZM200,880Q167,880 143.5,856.5Q120,833 120,800L200,800L200,880ZM540,880L540,800L620,800Q620,833 596.5,856.5Q573,880 540,880ZM120,320Q120,287 143.5,263.5Q167,240 200,240L200,320L120,320ZM540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400L540,400Q540,400 540,400Q540,400 540,400Z""" + ) + ) + } + + val createNewFolder: ImageVector by lazy { + materialIcon( + name = "CreateNewFolder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,640L640,640L640,560L720,560L720,480L640,480L640,400L560,400L560,480L480,480L480,560L560,560L560,640ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val delete: ImageVector by lazy { + materialIcon( + name = "Delete", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z""" + ) + ) + } + + val description: ImageVector by lazy { + materialIcon( + name = "Description", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,720L640,720L640,640L320,640L320,720ZM320,560L640,560L640,480L320,480L320,560ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z""" + ) + ) + } + + val doNotTouch: ImageVector by lazy { + materialIcon( + name = "DoNotTouch", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M840,726L760,646L760,200Q760,183 771.5,171.5Q783,160 800,160Q817,160 828.5,171.5Q840,183 840,200L840,726ZM360,246L280,166L280,160Q280,143 291.5,131.5Q303,120 320,120Q337,120 348.5,131.5Q360,143 360,160L360,246ZM520,406L440,326L440,80Q440,63 451.5,51.5Q463,40 480,40Q497,40 508.5,51.5Q520,63 520,80L520,406ZM680,487L600,487L600,487L600,120Q600,103 611.5,91.5Q623,80 640,80Q657,80 668.5,91.5Q680,103 680,120L680,487ZM717,830L360,473L360,697L212,593L369,822Q374,830 383,835Q392,840 402,840L680,840Q690,840 699.5,837.5Q709,835 717,830ZM402,920Q372,920 346,906.5Q320,893 303,868L48,495L72,472Q91,453 117,450Q143,447 164,462L280,543L280,393L27,140L84,83L876,875L819,932L775,888Q755,903 731,911.5Q707,920 680,920L402,920ZM539,652Q539,652 539,652Q539,652 539,652L539,652Q539,652 539,652Q539,652 539,652L539,652L539,652L539,652ZM600,487L600,487L600,487Z""" + ) + ) + } + + val download: ImageVector by lazy { + materialIcon( + name = "Download", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z""" + ) + ) + } + + val edit: ImageVector by lazy { + materialIcon( + name = "Edit", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z""" + ) + ) + } + + val email: ImageVector by lazy { + materialIcon( + name = "Email", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z""" + ) + ) + } + + val expandLess: ImageVector by lazy { + materialIcon( + name = "ExpandLess", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M296,615L240,559L480,319L720,559L664,615L480,431L296,615Z""" + ) + ) + } + + val expandMore: ImageVector by lazy { + materialIcon( + name = "ExpandMore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,615L240,375L296,319L480,503L664,319L720,375L480,615Z""" + ) + ) + } + + val favorite: ImageVector by lazy { + materialIcon( + name = "Favorite", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z""" + ) + ) + } + + val feedback: ImageVector by lazy { + materialIcon( + name = "Feedback", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z""" + ) + ) + } + + val fileOpen: ImageVector by lazy { + materialIcon( + name = "FileOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val filterList: ImageVector by lazy { + materialIcon( + name = "FilterList", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val folder: ImageVector by lazy { + materialIcon( + name = "Folder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val folderSpecial: ImageVector by lazy { + materialIcon( + name = "FolderSpecial", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,668L596,598L688,668L654,554L746,480L632,480L596,368L560,480L446,480L538,554L504,668ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z""" + ) + ) + } + + val formatListNumbered: ImageVector by lazy { + materialIcon( + name = "FormatListNumbered", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,880L120,820L220,820L220,790L160,790L160,730L220,730L220,700L120,700L120,640L240,640Q257,640 268.5,651.5Q280,663 280,680L280,720Q280,737 268.5,748.5Q257,760 240,760Q257,760 268.5,771.5Q280,783 280,800L280,840Q280,857 268.5,868.5Q257,880 240,880L120,880ZM120,600L120,490Q120,473 131.5,461.5Q143,450 160,450L220,450L220,420L120,420L120,360L240,360Q257,360 268.5,371.5Q280,383 280,400L280,470Q280,487 268.5,498.5Q257,510 240,510L180,510L180,540L280,540L280,600L120,600ZM180,320L180,140L120,140L120,80L240,80L240,320L180,320ZM360,760L360,680L840,680L840,760L360,760ZM360,520L360,440L840,440L840,520L360,520ZM360,280L360,200L840,200L840,280L360,280Z""" + ) + ) + } + + val fullscreen: ImageVector by lazy { + materialIcon( + name = "Fullscreen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,840L120,640L200,640L200,760L320,760L320,840L120,840ZM640,840L640,760L760,760L760,640L840,640L840,840L640,840ZM120,320L120,120L320,120L320,200L200,200L200,320L120,320ZM760,320L760,200L640,200L640,120L840,120L840,320L760,320Z""" + ) + ) + } + + val fullscreenExit: ImageVector by lazy { + materialIcon( + name = "FullscreenExit", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,840L240,720L120,720L120,640L320,640L320,840L240,840ZM640,840L640,640L840,640L840,720L720,720L720,840L640,840ZM120,320L120,240L240,240L240,120L320,120L320,320L120,320ZM640,320L640,120L720,120L720,240L840,240L840,320L640,320Z""" + ) + ) + } + + val gavel: ImageVector by lazy { + materialIcon( + name = "Gavel", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z""" + ) + ) + } + + val graphicEq: ImageVector by lazy { + materialIcon( + name = "GraphicEq", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z""" + ) + ) + } + + val importExport: ImageVector by lazy { + materialIcon( + name = "ImportExport", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,760L520,760L520,593L584,657L640,600L480,440L320,600L377,656L440,593L440,760ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z""" + ) + ) + } + + val info: ImageVector by lazy { + materialIcon( + name = "Info", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val keyboardArrowDown: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowDown", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z""" + ) + ) + } + + val keyboardArrowLeft: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowLeft", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z""" + ) + ) + } + + val keyboardArrowRight: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowRight", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z""" + ) + ) + } + + val keyboardArrowUp: ImageVector by lazy { + materialIcon( + name = "KeyboardArrowUp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,432L296,616L240,560L480,320L720,560L664,616L480,432Z""" + ) + ) + } + + val lock: ImageVector by lazy { + materialIcon( + name = "Lock", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680Q513,680 536.5,656.5ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val lockOpen: ImageVector by lazy { + materialIcon( + name = "LockOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680Q513,680 536.5,656.5ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val menu: ImageVector by lazy { + materialIcon( + name = "Menu", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,720L120,640L840,640L840,720L120,720ZM120,520L120,440L840,440L840,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z""" + ) + ) + } + + val moreVert: ImageVector by lazy { + materialIcon( + name = "MoreVert", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z""" + ) + ) + } + + val myLocation: ImageVector by lazy { + materialIcon( + name = "MyLocation", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,918L440,838Q315,824 225.5,734.5Q136,645 122,520L42,520L42,440L122,440Q136,315 225.5,225.5Q315,136 440,122L440,42L520,42L520,122Q645,136 734.5,225.5Q824,315 838,440L918,440L918,520L838,520Q824,645 734.5,734.5Q645,824 520,838L520,918L440,918ZM678,678Q760,596 760,480Q760,364 678,282Q596,200 480,200Q364,200 282,282Q200,364 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678ZM367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,546 593,593Q546,640 480,640Q414,640 367,593ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val openInNew: ImageVector by lazy { + materialIcon( + name = "OpenInNew", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z""" + ) + ) + } + + val palette: ImageVector by lazy { + materialIcon( + name = "Palette", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q398,880 325,848.5Q252,817 197.5,762.5Q143,708 111.5,635Q80,562 80,480Q80,397 112.5,324Q145,251 200.5,197Q256,143 330,111.5Q404,80 488,80Q568,80 639,107.5Q710,135 763.5,183.5Q817,232 848.5,298.5Q880,365 880,442Q880,557 810,618.5Q740,680 640,680L566,680Q557,680 553.5,685Q550,690 550,696Q550,708 565,730.5Q580,753 580,782Q580,832 552.5,856Q525,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM303,503Q320,486 320,460Q320,434 303,417Q286,400 260,400Q234,400 217,417Q200,434 200,460Q200,486 217,503Q234,520 260,520Q286,520 303,503ZM423,343Q440,326 440,300Q440,274 423,257Q406,240 380,240Q354,240 337,257Q320,274 320,300Q320,326 337,343Q354,360 380,360Q406,360 423,343ZM623,343Q640,326 640,300Q640,274 623,257Q606,240 580,240Q554,240 537,257Q520,274 520,300Q520,326 537,343Q554,360 580,360Q606,360 623,343ZM743,503Q760,486 760,460Q760,434 743,417Q726,400 700,400Q674,400 657,417Q640,434 640,460Q640,486 657,503Q674,520 700,520Q726,520 743,503ZM480,800Q489,800 494.5,795Q500,790 500,782Q500,768 485,749Q470,730 470,692Q470,650 499,625Q528,600 570,600L640,600Q706,600 753,561.5Q800,523 800,442Q800,321 707.5,240.5Q615,160 488,160Q352,160 256,253Q160,346 160,480Q160,613 253.5,706.5Q347,800 480,800Z""" + ) + ) + } + + val pause: ImageVector by lazy { + materialIcon( + name = "Pause", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z""" + ) + ) + } + + val phoneAndroid: ImageVector by lazy { + materialIcon( + name = "PhoneAndroid", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920ZM280,840L680,840Q680,840 680,840Q680,840 680,840L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM508.5,788.5Q520,777 520,760Q520,743 508.5,731.5Q497,720 480,720Q463,720 451.5,731.5Q440,743 440,760Q440,777 451.5,788.5Q463,800 480,800Q497,800 508.5,788.5Z""" + ) + ) + } + + val playArrow: ImageVector by lazy { + materialIcon( + name = "PlayArrow", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z""" + ) + ) + } + + val playCircle: ImageVector by lazy { + materialIcon( + name = "PlayCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M380,660L660,480L380,300L380,660ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val policy: ImageVector by lazy { + materialIcon( + name = "Policy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z""" + ) + ) + } + + val print: ImageVector by lazy { + materialIcon( + name = "Print", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M640,320L640,200L320,200L320,320L240,320L240,120L720,120L720,320L640,320ZM160,400L160,400Q160,400 171.5,400Q183,400 200,400L760,400Q777,400 788.5,400Q800,400 800,400L800,400L720,400L720,400L240,400L240,400L160,400ZM720,500Q737,500 748.5,488.5Q760,477 760,460Q760,443 748.5,431.5Q737,420 720,420Q703,420 691.5,431.5Q680,443 680,460Q680,477 691.5,488.5Q703,500 720,500ZM640,760L640,600L320,600L320,760L640,760ZM720,840L240,840L240,680L80,680L80,440Q80,389 115,354.5Q150,320 200,320L760,320Q811,320 845.5,354.5Q880,389 880,440L880,680L720,680L720,840ZM800,600L800,440Q800,423 788.5,411.5Q777,400 760,400L200,400Q183,400 171.5,411.5Q160,423 160,440L160,600L240,600L240,520L720,520L720,600L800,600Z""" + ) + ) + } + + val psychology: ImageVector by lazy { + materialIcon( + name = "Psychology", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880L240,708Q183,656 151.5,586.5Q120,517 120,440Q120,290 225,185Q330,80 480,80Q605,80 701.5,153.5Q798,227 827,345L879,550Q884,569 872,584.5Q860,600 840,600L760,600L760,720Q760,753 736.5,776.5Q713,800 680,800L600,800L600,880L520,880L520,720L680,720Q680,720 680,720Q680,720 680,720L680,520L788,520L750,365Q727,274 652,217Q577,160 480,160Q364,160 282,241Q200,322 200,438Q200,498 224.5,552Q249,606 294,648L320,672L320,880L240,880ZM494,520L494,520L494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520Q494,520 494,520L494,520L494,520L494,520Q494,520 494,520Q494,520 494,520L494,520L494,520ZM440,600L520,600L526,550Q534,547 540.5,543Q547,539 552,534L598,554L638,486L598,456Q600,448 600,440Q600,432 598,424L638,394L598,326L552,346Q547,341 540.5,337Q534,333 526,330L520,280L440,280L434,330Q426,333 419.5,337Q413,341 408,346L362,326L322,394L362,424Q360,432 360,440Q360,448 362,456L322,486L362,554L408,534Q413,539 419.5,543Q426,547 434,550L440,600ZM437.5,482.5Q420,465 420,440Q420,415 437.5,397.5Q455,380 480,380Q505,380 522.5,397.5Q540,415 540,440Q540,465 522.5,482.5Q505,500 480,500Q455,500 437.5,482.5Z""" + ) + ) + } + + val pushPin: ImageVector by lazy { + materialIcon( + name = "PushPin", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M640,480L720,560L720,640L520,640L520,880L480,920L440,880L440,640L240,640L240,560L320,480L320,200L280,200L280,120L680,120L680,200L640,200L640,480ZM354,560L606,560L560,514L560,200L400,200L400,514L354,560ZM480,560L480,560L480,560L480,560L480,560L480,560Z""" + ) + ) + } + + val refresh: ImageVector by lazy { + materialIcon( + name = "Refresh", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z""" + ) + ) + } + + val remove: ImageVector by lazy { + materialIcon( + name = "Remove", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M200,520L200,440L760,440L760,520L200,520Z""" + ) + ) + } + + val restore: ImageVector by lazy { + materialIcon( + name = "Restore", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840Q342,840 239.5,748.5Q137,657 122,520L204,520Q218,624 296.5,692Q375,760 480,760Q597,760 678.5,678.5Q760,597 760,480Q760,363 678.5,281.5Q597,200 480,200Q411,200 351,232Q291,264 250,320L360,320L360,400L120,400L120,160L200,160L200,254Q251,190 324.5,155Q398,120 480,120Q555,120 620.5,148.5Q686,177 734.5,225.5Q783,274 811.5,339.5Q840,405 840,480Q840,555 811.5,620.5Q783,686 734.5,734.5Q686,783 620.5,811.5Q555,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z""" + ) + ) + } + + val save: ImageVector by lazy { + materialIcon( + name = "Save", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Q530,720 565,685ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z""" + ) + ) + } + + val screenRotation: ImageVector by lazy { + materialIcon( + name = "ScreenRotation", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M496,778L183,464Q172,453 166,439Q160,425 160,410Q160,395 166,381Q172,367 183,356L356,183Q367,172 381,166.5Q395,161 410,161Q425,161 439,166.5Q453,172 464,183L777,496Q788,507 794,521Q800,535 800,550Q800,565 794,579Q788,593 777,604L604,778Q593,789 579,794.5Q565,800 550,800Q535,800 521,794.5Q507,789 496,778ZM550,720Q550,720 550,720Q550,720 550,720L720,550Q720,550 720,550Q720,550 720,550L410,240Q410,240 410,240Q410,240 410,240L240,410Q240,410 240,410Q240,410 240,410L550,720ZM480,960Q381,960 293.5,922.5Q206,885 140.5,819.5Q75,754 37.5,666.5Q0,579 0,480L80,480Q80,551 104,616Q128,681 170.5,733Q213,785 272,821.5Q331,858 401,873L296,768L352,712L588,948Q562,954 534.5,957Q507,960 480,960ZM880,480Q880,409 856,344Q832,279 789.5,227Q747,175 688,138.5Q629,102 559,87L664,192L608,248L372,12Q398,6 425.5,3Q453,0 480,0Q579,0 666.5,37.5Q754,75 819.5,140.5Q885,206 922.5,293.5Q960,381 960,480L880,480ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480ZM373,404Q386,404 394.5,395Q403,386 403,374Q403,361 394.5,352.5Q386,344 373,344Q361,344 352,352.5Q343,361 343,374Q343,386 352,395Q361,404 373,404Z""" + ) + ) + } + + val search: ImageVector by lazy { + materialIcon( + name = "Search", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """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 selectAll: ImageVector by lazy { + materialIcon( + name = "SelectAll", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z""" + ) + ) + } + + val settings: ImageVector by lazy { + materialIcon( + name = "Settings", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z""" + ) + ) + } + + val share: ImageVector by lazy { + materialIcon( + name = "Share", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z""" + ) + ) + } + + val skipNext: ImageVector by lazy { + materialIcon( + name = "SkipNext", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z""" + ) + ) + } + + val skipPrevious: ImageVector by lazy { + materialIcon( + name = "SkipPrevious", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z""" + ) + ) + } + + val smartphone: ImageVector by lazy { + materialIcon( + name = "Smartphone", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920ZM280,840L680,840Q680,840 680,840Q680,840 680,840L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5Z""" + ) + ) + } + + val star: ImageVector by lazy { + materialIcon( + name = "Star", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M354,673L480,597L606,674L573,530L684,434L538,421L480,285L422,420L276,433L387,530L354,673ZM233,840L298,559L80,370L368,345L480,80L592,345L880,370L662,559L727,840L480,691L233,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z""" + ) + ) + } + + val stop: ImageVector by lazy { + materialIcon( + name = "Stop", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M320,320L320,320L320,640L320,640L320,320ZM240,720L240,240L720,240L720,720L240,720ZM320,640L640,640L640,320L320,320L320,640Z""" + ) + ) + } + + val swapHoriz: ImageVector by lazy { + materialIcon( + name = "SwapHoriz", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,800L80,600L280,400L336,457L233,560L520,560L520,640L233,640L336,743L280,800ZM680,560L624,503L727,400L440,400L440,320L727,320L624,217L680,160L880,360L680,560Z""" + ) + ) + } + + val sync: ImageVector by lazy { + materialIcon( + name = "Sync", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800L160,720L270,720L254,706Q202,660 181,601Q160,542 160,482Q160,371 226.5,284.5Q293,198 400,170L400,254Q328,280 284,342.5Q240,405 240,482Q240,527 257,569.5Q274,612 310,648L320,658L320,560L400,560L400,800L160,800ZM560,790L560,706Q632,680 676,617.5Q720,555 720,478Q720,433 703,390.5Q686,348 650,312L640,302L640,400L560,400L560,160L800,160L800,240L690,240L706,254Q755,303 777.5,360.5Q800,418 800,478Q800,589 733.5,675.5Q667,762 560,790Z""" + ) + ) + } + + val tag: ImageVector by lazy { + materialIcon( + name = "Tag", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z""" + ) + ) + } + + val textFields: ImageVector by lazy { + materialIcon( + name = "TextFields", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M280,800L280,280L80,280L80,160L600,160L600,280L400,280L400,800L280,800ZM640,800L640,480L520,480L520,360L880,360L880,480L760,480L760,800L640,800Z""" + ) + ) + } + + val touchApp: ImageVector by lazy { + materialIcon( + name = "TouchApp", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M419,880Q391,880 366.5,868Q342,856 325,834L107,557L126,537Q146,516 174,512Q202,508 226,523L300,568L300,240Q300,223 311.5,211.5Q323,200 340,200Q357,200 369,211.5Q381,223 381,240L381,712L284,652L388,785Q394,792 402,796Q410,800 419,800L640,800Q673,800 696.5,776.5Q720,753 720,720L720,560Q720,543 708.5,531.5Q697,520 680,520L461,520L461,440L680,440Q730,440 765,475Q800,510 800,560L800,720Q800,786 753,833Q706,880 640,880L419,880ZM167,340Q154,318 147,292.5Q140,267 140,240Q140,157 198.5,98.5Q257,40 340,40Q423,40 481.5,98.5Q540,157 540,240Q540,267 533,292.5Q526,318 513,340L444,300Q452,286 456,271.5Q460,257 460,240Q460,190 425,155Q390,120 340,120Q290,120 255,155Q220,190 220,240Q220,257 224,271.5Q228,286 236,300L167,340ZM502,620L502,620L502,620L502,620Q502,620 502,620Q502,620 502,620L502,620Q502,620 502,620Q502,620 502,620L502,620Q502,620 502,620Q502,620 502,620L502,620L502,620Z""" + ) + ) + } + + val translate: ImageVector by lazy { + materialIcon( + name = "Translate", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M476,880L658,400L742,400L924,880L840,880L797,758L603,758L560,880L476,880ZM160,760L104,704L306,502Q271,467 242.5,422Q214,377 190,320L274,320Q294,359 314,388Q334,417 362,446Q395,413 430.5,353.5Q466,294 484,240L40,240L40,160L320,160L320,80L400,80L400,160L680,160L680,240L564,240Q543,312 501,388Q459,464 418,504L514,602L484,684L362,559L160,760ZM628,688L772,688L700,484L628,688Z""" + ) + ) + } + + val tune: ImageVector by lazy { + materialIcon( + name = "Tune", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M440,840L440,600L520,600L520,680L840,680L840,760L520,760L520,840L440,840ZM120,760L120,680L360,680L360,760L120,760ZM280,600L280,520L120,520L120,440L280,440L280,360L360,360L360,600L280,600ZM440,520L440,440L840,440L840,520L440,520ZM600,360L600,120L680,120L680,200L840,200L840,280L680,280L680,360L600,360ZM120,280L120,200L520,200L520,280L120,280Z""" + ) + ) + } + + val verified: ImageVector by lazy { + materialIcon( + name = "Verified", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M344,900L268,772L124,740L138,592L40,480L138,368L124,220L268,188L344,60L480,118L616,60L692,188L836,220L822,368L920,480L822,592L836,740L692,772L616,900L480,842L344,900ZM378,798L480,754L584,798L640,702L750,676L740,564L814,480L740,394L750,282L640,258L582,162L480,206L376,162L320,258L210,282L220,394L146,480L220,564L210,678L320,702L378,798ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480ZM438,622L664,396L608,338L438,508L352,424L296,480L438,622Z""" + ) + ) + } + + val verifiedUser: ImageVector by lazy { + materialIcon( + name = "VerifiedUser", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M438,622L664,396L607,339L438,508L354,424L297,481L438,622ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z""" + ) + ) + } + + val visibility: ImageVector by lazy { + materialIcon( + name = "Visibility", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760Q334,760 214,678.5ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z""" + ) + ) + } + + val visibilityOff: ImageVector by lazy { + materialIcon( + name = "VisibilityOff", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M644,532L586,474Q595,427 559,386Q523,345 466,354L408,296Q425,288 442.5,284Q460,280 480,280Q555,280 607.5,332.5Q660,385 660,460Q660,480 656,497.5Q652,515 644,532ZM772,658L714,602Q752,573 781.5,538.5Q811,504 832,460Q782,359 688.5,299.5Q595,240 480,240Q451,240 423,244Q395,248 368,256L306,194Q347,177 390,168.5Q433,160 480,160Q631,160 749,243.5Q867,327 920,460Q897,519 859.5,569.5Q822,620 772,658ZM792,904L624,738Q589,749 553.5,754.5Q518,760 480,760Q329,760 211,676.5Q93,593 40,460Q61,407 93,361.5Q125,316 166,280L56,168L112,112L848,848L792,904ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 271.5,620.5Q365,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z""" + ) + ) + } + + val zoomOut: ImageVector by lazy { + materialIcon( + name = "ZoomOut", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M120,840L120,600L200,600L200,704L324,580L380,636L256,760L360,760L360,840L120,840ZM600,840L600,760L704,760L580,636L636,580L760,704L760,600L840,600L840,840L600,840ZM324,380L200,256L200,360L120,360L120,120L360,120L360,200L256,200L380,324L324,380ZM636,380L580,324L704,200L600,200L600,120L840,120L840,360L760,360L760,256L636,380Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt new file mode 100644 index 0000000..7eaa644 --- /dev/null +++ b/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt @@ -0,0 +1,159 @@ +@file:Suppress("ObjectPropertyName", "unused") + +package androidx.compose.material.icons.outlined + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +val Icons.Outlined.AccountCircle: ImageVector + get() = EpistemeOutlinedIcons.accountCircle + +val Icons.Outlined.Email: ImageVector + get() = EpistemeOutlinedIcons.email + +val Icons.Outlined.FavoriteBorder: ImageVector + get() = EpistemeOutlinedIcons.favoriteBorder + +val Icons.Outlined.Feedback: ImageVector + get() = EpistemeOutlinedIcons.feedback + +val Icons.Outlined.FileOpen: ImageVector + get() = EpistemeOutlinedIcons.fileOpen + +val Icons.Outlined.Gavel: ImageVector + get() = EpistemeOutlinedIcons.gavel + +val Icons.Outlined.Policy: ImageVector + get() = EpistemeOutlinedIcons.policy + +private object EpistemeOutlinedIcons { + val accountCircle: ImageVector by lazy { + materialIcon( + name = "AccountCircle", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z""" + ) + ) + } + + val email: ImageVector by lazy { + materialIcon( + name = "Email", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z""" + ) + ) + } + + val favoriteBorder: ImageVector by lazy { + materialIcon( + name = "FavoriteBorder", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z""" + ) + ) + } + + val feedback: ImageVector by lazy { + materialIcon( + name = "Feedback", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z""" + ) + ) + } + + val fileOpen: ImageVector by lazy { + materialIcon( + name = "FileOpen", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z""" + ) + ) + } + + val gavel: ImageVector by lazy { + materialIcon( + name = "Gavel", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z""" + ) + ) + } + + val policy: ImageVector by lazy { + materialIcon( + name = "Policy", + defaultWidth = 24f, + defaultHeight = 24f, + viewportWidth = 960f, + viewportHeight = 960f, + autoMirror = false, + paths = listOf( + """M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z""" + ) + ) + } + +} + +private fun materialIcon( + name: String, + defaultWidth: Float, + defaultHeight: Float, + viewportWidth: Float, + viewportHeight: Float, + autoMirror: Boolean, + paths: List +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth.dp, + defaultHeight = defaultHeight.dp, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + autoMirror = autoMirror + ).apply { + paths.forEach { pathData -> + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + } + }.build() +} + diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt index bfd18c3..62d02b0 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt @@ -138,30 +138,340 @@ object CssParser { } } - private fun splitDeclarations(declarations: String): List { - val parts = declarations.split(';').toMutableList() - if (parts.size <= 1) return parts + private data class CssBlock(val header: String, val body: String, val sourceOrder: Int) + private data class ParsedSelector(val selector: String, val pseudoElement: String?) + private fun splitDeclarations(declarations: String): List { val result = mutableListOf() - val iterator = parts.listIterator() - while(iterator.hasNext()) { - var current = iterator.next() - val originalCurrent = current - var reassembled = false - while (current.count { it == '(' } > current.count { it == ')' }) { - if (!iterator.hasNext()) break - val nextPart = iterator.next() - current += ";$nextPart" - reassembled = true + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + + declarations.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch == '[' -> { + current.append(ch) + bracketDepth++ + } + ch == ']' -> { + current.append(ch) + bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + } + ch == ';' && parenDepth == 0 && bracketDepth == 0 -> { + result += current.toString() + current.clear() + } + else -> current.append(ch) } - if (reassembled) { - ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'") - } - result.add(current) } + if (current.isNotBlank()) result += current.toString() return result } + private fun stripCssComments(css: String): String { + val result = StringBuilder(css.length) + var index = 0 + var quote: Char? = null + var escaped = false + while (index < css.length) { + val ch = css[index] + if (escaped) { + result.append(ch) + escaped = false + index++ + continue + } + if (ch == '\\') { + result.append(ch) + escaped = true + index++ + continue + } + if (quote != null) { + result.append(ch) + if (ch == quote) quote = null + index++ + continue + } + if (ch == '"' || ch == '\'') { + quote = ch + result.append(ch) + index++ + continue + } + if (ch == '/' && index + 1 < css.length && css[index + 1] == '*') { + index += 2 + while (index + 1 < css.length && !(css[index] == '*' && css[index + 1] == '/')) { + index++ + } + index = (index + 2).coerceAtMost(css.length) + continue + } + result.append(ch) + index++ + } + return result.toString() + } + + private fun parseCssBlocks( + css: String, + constraints: Constraints, + isDarkTheme: Boolean, + adaptThemeColors: Boolean, + sourceCounter: IntArray = intArrayOf(0) + ): Pair, List> { + val blocks = mutableListOf() + val fontFaceBlocks = mutableListOf() + var index = 0 + + fun skipWhitespace() { + while (index < css.length && css[index].isWhitespace()) index++ + } + + fun findMatchingBrace(openBrace: Int): Int { + var depth = 1 + var i = openBrace + 1 + var quote: Char? = null + var escaped = false + while (i < css.length) { + val ch = css[i] + when { + escaped -> escaped = false + ch == '\\' -> escaped = true + quote != null -> if (ch == quote) quote = null + ch == '"' || ch == '\'' -> quote = ch + ch == '{' -> depth++ + ch == '}' -> { + depth-- + if (depth == 0) return i + } + } + i++ + } + return css.lastIndex + } + + while (index < css.length) { + skipWhitespace() + if (index >= css.length) break + val headerStart = index + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + while (index < css.length) { + val ch = css[index] + when { + escaped -> escaped = false + ch == '\\' -> escaped = true + quote != null -> if (ch == quote) quote = null + ch == '"' || ch == '\'' -> quote = ch + ch == '(' -> parenDepth++ + ch == ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ch == '[' -> bracketDepth++ + ch == ']' -> bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + ch == ';' && parenDepth == 0 && bracketDepth == 0 -> { + index++ + break + } + ch == '{' && parenDepth == 0 && bracketDepth == 0 -> break + } + index++ + } + if (index >= css.length || css[index] != '{') continue + + val header = css.substring(headerStart, index).trim() + val close = findMatchingBrace(index) + val body = css.substring(index + 1, close.coerceAtMost(css.length)) + index = close + 1 + + when { + header.startsWith("@media", ignoreCase = true) -> { + if (mediaQueryApplies(header, constraints, isDarkTheme, adaptThemeColors)) { + val nested = parseCssBlocks(body, constraints, isDarkTheme, adaptThemeColors, sourceCounter) + blocks += nested.first + fontFaceBlocks += nested.second + } + } + header.startsWith("@supports", ignoreCase = true) -> { + val nested = parseCssBlocks(body, constraints, isDarkTheme, adaptThemeColors, sourceCounter) + blocks += nested.first + fontFaceBlocks += nested.second + } + header.startsWith("@font-face", ignoreCase = true) -> fontFaceBlocks += body + header.startsWith("@") -> Unit + header.isNotBlank() -> blocks += CssBlock(header, body, sourceCounter[0]++) + } + } + + return blocks to fontFaceBlocks + } + + private fun mediaQueryApplies( + header: String, + constraints: Constraints, + isDarkTheme: Boolean, + adaptThemeColors: Boolean + ): Boolean { + val query = header.removePrefix("@media").trim().lowercase() + if (query.isBlank() || query == "all" || query == "screen") return true + if (query.contains("print")) return false + if (query.contains("prefers-color-scheme")) { + val wantsDark = query.contains("prefers-color-scheme") && query.contains("dark") + val wantsLight = query.contains("prefers-color-scheme") && query.contains("light") + if (!adaptThemeColors) return !wantsDark + if (wantsDark && !isDarkTheme) return false + if (wantsLight && isDarkTheme) return false + } + Regex("""min-width\s*:\s*([^)]+)""").findAll(query).forEach { match -> + val minWidth = parseCssDimension(match.groupValues[1], 16f, 1f, constraints.maxWidth) + if (minWidth.isSpecified && minWidth.value > constraints.maxWidth) return false + } + Regex("""max-width\s*:\s*([^)]+)""").findAll(query).forEach { match -> + val maxWidth = parseCssDimension(match.groupValues[1], 16f, 1f, constraints.maxWidth) + if (maxWidth.isSpecified && maxWidth.value < constraints.maxWidth) return false + } + return true + } + + private fun splitCssList(value: String, delimiter: Char): List { + val result = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + var bracketDepth = 0 + value.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch == '[' -> { + current.append(ch) + bracketDepth++ + } + ch == ']' -> { + current.append(ch) + bracketDepth = (bracketDepth - 1).coerceAtLeast(0) + } + ch == delimiter && parenDepth == 0 && bracketDepth == 0 -> { + result += current.toString() + current.clear() + } + else -> current.append(ch) + } + } + if (current.isNotBlank()) result += current.toString() + return result + } + + private fun splitCssTokens(value: String): List { + val result = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaped = false + var parenDepth = 0 + value.forEach { ch -> + when { + escaped -> { + current.append(ch) + escaped = false + } + ch == '\\' -> { + current.append(ch) + escaped = true + } + quote != null -> { + current.append(ch) + if (ch == quote) quote = null + } + ch == '"' || ch == '\'' -> { + current.append(ch) + quote = ch + } + ch == '(' -> { + current.append(ch) + parenDepth++ + } + ch == ')' -> { + current.append(ch) + parenDepth = (parenDepth - 1).coerceAtLeast(0) + } + ch.isWhitespace() && parenDepth == 0 -> { + if (current.isNotBlank()) { + result += current.toString() + current.clear() + } + } + else -> current.append(ch) + } + } + if (current.isNotBlank()) result += current.toString() + return result + } + + private fun parseSelector(selector: String): ParsedSelector { + var pseudoElement: String? = null + var sanitized = selector + Regex("::?(before|after)\\b", RegexOption.IGNORE_CASE).find(sanitized)?.let { match -> + pseudoElement = match.groupValues[1].lowercase() + sanitized = sanitized.removeRange(match.range) + } + sanitized = sanitized + .replace(Regex(":(link|visited|hover|active|focus)\\b", RegexOption.IGNORE_CASE), "") + .replace(Regex("::?(first-letter|first-line|marker|selection)\\b", RegexOption.IGNORE_CASE), "") + .replace(Regex(":root\\b", RegexOption.IGNORE_CASE), "html") + .trim() + return ParsedSelector(sanitized, pseudoElement) + } + private fun calculateSpecificity(selector: String): Int { val ids = ID_SELECTOR_REGEX.findAll(selector).count() val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count() @@ -187,46 +497,38 @@ object CssParser { val otherComplex = mutableListOf() val fontFaces = mutableListOf() - val blockRegex = "([^{}]+)\\s*\\{([^}]+)\\}".toRegex() - - var cleanedCss = cssContent.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "") - - val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}") - mediaQueryRegex.findAll(cleanedCss).forEach { match -> - val condition = match.groups[0]?.value?.trim() ?: "" - if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) { - val darkCss = match.groups[1]?.value ?: "" - cleanedCss += "\n$darkCss" - } - } - cleanedCss = mediaQueryRegex.replace(cleanedCss, "") - - ReaderCssLog.d("CssParser: Checking for @font-face rules...") - val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss) - if (!fontFaceMatches.any()) { - ReaderCssLog.d("CssParser: No @font-face rules found by regex.") - } - fontFaceMatches.forEach { match -> - ReaderCssLog.d("CssParser: Found a @font-face block. Parsing its properties.") - val properties = match.groupValues[1] + val cleanedCss = stripCssComments(cssContent) + val (styleBlocks, fontFaceBlocks) = parseCssBlocks( + css = cleanedCss, + constraints = constraints, + isDarkTheme = isDarkTheme, + adaptThemeColors = adaptThemeColors + ) + fontFaceBlocks.forEach { properties -> parseFontFace(properties, cssPath)?.let { fontFaces.add(it) } } - cleanedCss = FONT_FACE_REGEX.replace(cleanedCss, "") - blockRegex.findAll(cleanedCss).forEach { matchResult -> - val selectorGroup = matchResult.groups[1]?.value?.trim() ?: "" - val propertiesGroup = matchResult.groups[2]?.value?.trim() ?: "" + val rootCustomProperties = styleBlocks + .filter { block -> + splitCssList(block.header, ',').any { selector -> + val normalized = selector.trim().lowercase() + normalized == ":root" || normalized == "html" || normalized == "body" + } + } + .fold(emptyMap()) { acc, block -> acc + extractCustomProperties(block.body, acc) } - val selectors = selectorGroup.split(',').map { it.trim() } + val allRules = mutableListOf() + styleBlocks.forEach { block -> + val selectorGroup = block.header.trim() + val propertiesGroup = block.body.trim() + val selectors = splitCssList(selectorGroup, ',').map { it.trim() } for (originalSelector in selectors) { - if (originalSelector.isBlank() || originalSelector.startsWith("@")) { + if (originalSelector.isBlank()) { continue } - val sanitizedSelector = originalSelector.replace( - Regex(":(link|visited|hover|active|focus)\\b|::(first-letter|first-line|marker)\\b", RegexOption.IGNORE_CASE), - "" - ).trim() + val parsedSelector = parseSelector(originalSelector) + val sanitizedSelector = parsedSelector.selector if (sanitizedSelector.isBlank()) { continue } @@ -240,7 +542,8 @@ object CssParser { isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor, - adaptThemeColors = adaptThemeColors + adaptThemeColors = adaptThemeColors, + inheritedCustomProperties = rootCustomProperties ) val importantStyle = parseProperties( properties = propertiesGroup, @@ -251,18 +554,25 @@ object CssParser { isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor, - adaptThemeColors = adaptThemeColors + adaptThemeColors = adaptThemeColors, + inheritedCustomProperties = rootCustomProperties ) fun addRule(style: CssStyle, spec: Int) { if (style == CssStyle()) return - val rule = CssRule(CssSelector(sanitizedSelector, spec), style) + val rule = CssRule( + selector = CssSelector(sanitizedSelector, spec), + style = style, + pseudoElement = parsedSelector.pseudoElement, + sourceOrder = block.sourceOrder + ) + allRules += rule when { - SIMPLE_ID_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_ID_SELECTOR.matches(sanitizedSelector) -> byId.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule) - SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) -> byClass.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule) - SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) -> + parsedSelector.pseudoElement == null && SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) -> byTag.getOrPut(sanitizedSelector) { mutableListOf() }.add(rule) else -> otherComplex.add(rule) } @@ -272,7 +582,7 @@ object CssParser { addRule(importantStyle, specificity + IMPORTANT_SPECIFICITY_BOOST) } } - val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex) + val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex, allRules) return OptimizedCssParseResult(optimizedRules, fontFaces) } @@ -392,14 +702,21 @@ object CssParser { isDarkTheme: Boolean, themeBackgroundColor: Color = Color.Unspecified, themeTextColor: Color = Color.Unspecified, - adaptThemeColors: Boolean = true + adaptThemeColors: Boolean = true, + inheritedCustomProperties: Map = emptyMap() ): CssStyle { + val localCustomProperties = if (!onlyImportant) extractCustomProperties(properties, inheritedCustomProperties) else emptyMap() + val customProperties = inheritedCustomProperties + localCustomProperties + var hasApplicableDeclaration = false var spanStyle = SpanStyle() var paragraphStyle = ParagraphStyle() var padding = BoxBorders() var width: Dp = Dp.Unspecified var maxWidth: Dp = Dp.Unspecified var height: Dp = Dp.Unspecified + var minWidth: Dp = Dp.Unspecified + var minHeight: Dp = Dp.Unspecified + var maxHeight: Dp = Dp.Unspecified var backgroundColor: Color = Color.Unspecified // Changed: Track the max width found to prioritize visible borders @@ -432,6 +749,18 @@ object CssParser { var borderCollapse: String? = null var borderSpacing: Dp = 0.dp var borderRadius: Dp = 0.dp + var overflow: String? = null + var breakBefore: String? = null + var breakAfter: String? = null + var breakInside: String? = null + var widows: Int = 2 + var orphans: Int = 2 + var visibility: String? = null + var objectFit: String? = null + var objectPosition: String? = null + var backgroundImage: String? = null + var whiteSpace: String? = null + var verticalAlign: String? = null var hyphens: String? = null var fontVariantNumeric: String? = null var textEmphasisStyleString: String? = null @@ -481,15 +810,21 @@ object CssParser { val key = parts[0].lowercase() val valueWithImportant = parts[1] val isImportant = valueWithImportant.contains("!important", ignoreCase = true) + if (key.startsWith("--")) { + return@forEach + } if (isImportant != onlyImportant) { return@forEach } - val value = if (isImportant) { + hasApplicableDeclaration = true + val rawValue = if (isImportant) { valueWithImportant.replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim() } else { - valueWithImportant + valueWithImportant.trim() } + val value = resolveCssVariables(rawValue, customProperties) + val valueLower = value.trim().lowercase() fun updateUnifiedBorder( widthStr: String?, @@ -497,7 +832,7 @@ object CssParser { styleStr: String? ) { val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp - val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) } + val parsedColor = colorStr?.let { parseColor(it, spanStyle.color) }?.let { maybeAdaptColor(it, isBackground = false) } val isExplicitWidth = widthStr != null @@ -516,7 +851,7 @@ object CssParser { when (key) { "font-family" -> { - fontFamilies = value.split(',') + fontFamilies = splitCssList(value, ',') .map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() } } "font-size" -> { @@ -533,7 +868,7 @@ object CssParser { } } "font-weight" -> { - spanStyle = spanStyle.copy(fontWeight = when (value) { + spanStyle = spanStyle.copy(fontWeight = when (valueLower) { "bold" -> FontWeight.Bold "700" -> FontWeight.Bold "600" -> FontWeight.SemiBold @@ -543,30 +878,31 @@ object CssParser { "100" -> FontWeight.Thin "normal" -> FontWeight.Normal "400" -> FontWeight.Normal - else -> value.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight + else -> valueLower.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight }) } "font-style" -> { - if (value == "italic" || value == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic) - else if (value == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal) + if (valueLower == "italic" || valueLower == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic) + else if (valueLower == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal) } "color" -> { - parseColor(value)?.let { + parseColor(value, spanStyle.color)?.let { spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false)) } } "text-align" -> { - val align = when (value) { + val align = when (valueLower) { "center" -> TextAlign.Center - "right" -> TextAlign.End + "right", "end" -> TextAlign.End "justify" -> TextAlign.Justify + "left", "start" -> TextAlign.Start else -> TextAlign.Start } paragraphStyle = paragraphStyle.copy(textAlign = align) } "line-height" -> { val trimmedValue = value.trim() - var lineHeight = when { + val lineHeight = when { trimmedValue.endsWith("%") -> { val percentage = trimmedValue.removeSuffix("%").toFloatOrNull() if (percentage != null) { @@ -580,9 +916,6 @@ object CssParser { } else -> parseCssDimensionToTextUnit(trimmedValue, containerWidthPx, density) } - if (lineHeight.isEm && lineHeight.value < 1.2f && lineHeight.value > 0) { - lineHeight = 2f.em - } if (lineHeight != TextUnit.Unspecified) { paragraphStyle = paragraphStyle.copy(lineHeight = lineHeight) } @@ -594,7 +927,7 @@ object CssParser { } } "text-decoration" -> { - val parts = value.split(" ") + val parts = splitCssTokens(valueLower) val decos = mutableListOf() if (parts.contains("underline")) decos.add(TextDecoration.Underline) @@ -608,7 +941,7 @@ object CssParser { val styles = listOf("solid", "double", "dotted", "dashed", "wavy") parts.firstOrNull { it in styles }?.let { textDecorationStyle = it } - parts.firstNotNullOfOrNull { parseColor(it) }?.let { color -> + parts.firstNotNullOfOrNull { parseColor(it, spanStyle.color) }?.let { color -> textDecorationColor = maybeAdaptColor(color, isBackground = false) } } @@ -621,10 +954,10 @@ object CssParser { } } "text-decoration-style" -> { - textDecorationStyle = value + textDecorationStyle = valueLower } "text-decoration-color" -> { - parseColor(value)?.let { + parseColor(value, spanStyle.color)?.let { textDecorationColor = maybeAdaptColor(it, isBackground = false) } } @@ -638,8 +971,8 @@ object CssParser { } } "text-transform" -> { - textTransform = when (value) { - "uppercase", "lowercase", "capitalize", "none" -> value + textTransform = when (valueLower) { + "uppercase", "lowercase", "capitalize", "none" -> valueLower else -> null } } @@ -649,7 +982,7 @@ object CssParser { } } "margin" -> { - val marginParts = value.split(' ').filter { it.isNotBlank() } + val marginParts = splitCssTokens(value) when (marginParts.size) { 1 -> { marginTopStr = marginParts[0]; marginRightStr = marginParts[0]; marginBottomStr = marginParts[0]; marginLeftStr = marginParts[0] @@ -682,11 +1015,25 @@ object CssParser { "width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "min-width" -> minWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "min-height" -> minHeight = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "max-height" -> maxHeight = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "background-color" -> { - val originalColor = parseColor(value) ?: Color.Unspecified + val originalColor = parseColor(value, spanStyle.color) ?: Color.Unspecified backgroundColor = maybeAdaptColor(originalColor, isBackground = true) } + "background-image" -> backgroundImage = value.takeIf { valueLower != "none" } + "background" -> { + URL_REGEX.find(value)?.groupValues?.getOrNull(2)?.takeIf { it.isNotBlank() }?.let { + backgroundImage = it + } + splitCssTokens(value).firstNotNullOfOrNull { token -> + parseColor(token, spanStyle.color) + }?.let { color -> + backgroundColor = maybeAdaptColor(color, isBackground = true) + } + } // Border Properties "border-width" -> { @@ -707,15 +1054,15 @@ object CssParser { "border-bottom-width" -> borderBottomWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-left-width" -> borderLeftWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) - "border-top-style" -> borderTopStyle = value - "border-right-style" -> borderRightStyle = value - "border-bottom-style" -> borderBottomStyle = value - "border-left-style" -> borderLeftStyle = value + "border-top-style" -> borderTopStyle = valueLower + "border-right-style" -> borderRightStyle = valueLower + "border-bottom-style" -> borderBottomStyle = valueLower + "border-left-style" -> borderLeftStyle = valueLower - "border-top-color" -> borderTopColor = parseColor(value) - "border-right-color" -> borderRightColor = parseColor(value) - "border-bottom-color" -> borderBottomColor = parseColor(value) - "border-left-color" -> borderLeftColor = parseColor(value) + "border-top-color" -> borderTopColor = parseColor(value, spanStyle.color) + "border-right-color" -> borderRightColor = parseColor(value, spanStyle.color) + "border-bottom-color" -> borderBottomColor = parseColor(value, spanStyle.color) + "border-left-color" -> borderLeftColor = parseColor(value, spanStyle.color) "border-top" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) @@ -762,8 +1109,8 @@ object CssParser { "border-bottom-left-radius" -> borderBottomLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-collapse" -> { - if (value in listOf("collapse", "separate")) { - borderCollapse = value + if (valueLower in listOf("collapse", "separate")) { + borderCollapse = valueLower } } "border-spacing" -> { @@ -771,51 +1118,82 @@ object CssParser { } "list-style-type" -> { - listStyleType = value + listStyleType = valueLower } "list-style-image" -> { URL_REGEX.find(value)?.groupValues?.get(2)?.let { listStyleImage = it } } + "list-style" -> { + URL_REGEX.find(value)?.groupValues?.get(2)?.takeIf { it.isNotBlank() }?.let { + listStyleImage = it + } + val positions = setOf("inside", "outside") + splitCssTokens(valueLower) + .firstOrNull { token -> token !in positions && !token.startsWith("url(") } + ?.let { listStyleType = it } + } "page-break-inside" -> { - if (value == "avoid") { + if (valueLower == "avoid") { pageBreakInsideAvoid = true } + breakInside = normalizeBreakValue(valueLower) } "page-break-after" -> { - if (value == "avoid") { + if (valueLower == "avoid") { pageBreakAfterAvoid = true } + breakAfter = normalizeBreakValue(valueLower) } - "display" -> display = value - "flex-direction" -> flexDirection = value - "justify-content" -> justifyContent = value - "align-items" -> alignItems = value - "filter" -> filter = value - "box-sizing" -> boxSizing = value - "content" -> content = value.removeSurrounding("\"").removeSurrounding("'") - "position" -> position = value + "page-break-before" -> breakBefore = normalizeBreakValue(valueLower) + "break-before" -> breakBefore = normalizeBreakValue(valueLower) + "break-after" -> breakAfter = normalizeBreakValue(valueLower) + "break-inside" -> breakInside = normalizeBreakValue(valueLower) + "display" -> display = valueLower + "flex-direction" -> flexDirection = valueLower + "justify-content" -> justifyContent = valueLower + "align-items" -> alignItems = valueLower + "filter" -> filter = valueLower + "box-sizing" -> boxSizing = valueLower + "content" -> content = value + "position" -> position = valueLower "left" -> left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "right" -> right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "top" -> top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "bottom" -> bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "float" -> { - if (value in listOf("left", "right", "none")) { - float = value + if (valueLower in listOf("left", "right", "none")) { + float = valueLower } } "hyphens", "-webkit-hyphens", "-moz-hyphens", "-epub-hyphens", "adobe-hyphenate" -> { - if (value in listOf("auto", "manual", "none")) { - hyphens = value + if (valueLower in listOf("auto", "manual", "none")) { + hyphens = valueLower } } - "font-variant-numeric" -> { - fontVariantNumeric = value + "white-space" -> { + if (valueLower in listOf("normal", "nowrap", "pre", "pre-wrap", "pre-line", "break-spaces")) { + whiteSpace = valueLower + } } + "visibility" -> { + if (valueLower in listOf("visible", "hidden", "collapse")) visibility = valueLower + } + "overflow" -> { + if (valueLower in listOf("visible", "hidden", "clip", "scroll", "auto")) overflow = valueLower + } + "font-variant-numeric" -> { + fontVariantNumeric = valueLower + } + "widows" -> widows = valueLower.toIntOrNull()?.coerceAtLeast(1) ?: widows + "orphans" -> orphans = valueLower.toIntOrNull()?.coerceAtLeast(1) ?: orphans + "vertical-align" -> verticalAlign = valueLower + "object-fit" -> objectFit = valueLower + "object-position" -> objectPosition = rawValue "clear" -> { - if (value in listOf("left", "right", "both", "none")) { - clear = value + if (valueLower in listOf("left", "right", "both", "none")) { + clear = valueLower } } "text-emphasis", "-epub-text-emphasis" -> { @@ -825,16 +1203,19 @@ object CssParser { textEmphasisStyleString = value } "text-emphasis-color", "-epub-text-emphasis-color" -> { - textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) } + textEmphasisColor = parseColor(value, spanStyle.color)?.let { maybeAdaptColor(it, isBackground = false) } } "text-emphasis-position", "-epub-text-emphasis-position" -> { - if (value in listOf("over", "under")) { - textEmphasisPositionString = value + if (valueLower in listOf("over", "under")) { + textEmphasisPositionString = valueLower } } } } } + if (!hasApplicableDeclaration && localCustomProperties.isEmpty()) { + return CssStyle() + } val finalHorizontalAlign = if (marginLeftStr == "auto" && marginRightStr == "auto") "center" else null val margin = BoxBorders( @@ -926,16 +1307,29 @@ object CssParser { horizontalAlign = finalHorizontalAlign, filter = filter, borderCollapse = borderCollapse, - borderSpacing = borderSpacing + borderSpacing = borderSpacing, + minWidth = minWidth, + minHeight = minHeight, + maxHeight = maxHeight, + overflow = overflow, + breakBefore = breakBefore, + breakAfter = breakAfter, + breakInside = breakInside, + widows = widows, + orphans = orphans, + visibility = visibility, + objectFit = objectFit, + objectPosition = objectPosition, + backgroundImage = backgroundImage ) return CssStyle( spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis, - wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset + wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset, whiteSpace, verticalAlign, customProperties ) } private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List { - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value) val dps = parts.map { parseCssSizeToDp(it, baseFontSize, density, containerWidth) } return when (dps.size) { 1 -> listOf(dps[0], dps[0], dps[0], dps[0]) @@ -947,7 +1341,7 @@ object CssParser { } private fun parseShorthand4Strings(value: String): List { - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value).map { it.lowercase() } return when (parts.size) { 1 -> listOf(parts[0], parts[0], parts[0], parts[0]) 2 -> listOf(parts[0], parts[1], parts[0], parts[1]) @@ -962,7 +1356,7 @@ object CssParser { val c = parseColor(value) return listOf(c, c, c, c) } - val parts = value.split(' ').filter { it.isNotBlank() } + val parts = splitCssTokens(value) val colors = parts.map { parseColor(it) } return when (colors.size) { 1 -> listOf(colors[0], colors[0], colors[0], colors[0]) @@ -974,7 +1368,7 @@ object CssParser { } private fun parseBorderShorthand(value: String, baseFontSize: Float, density: Float, containerWidth: Int): Triple { - val parts = value.split(" ").filter { it.isNotBlank() } + val parts = splitCssTokens(value) var w: Dp? = null var s: String? = null var c: Color? = null @@ -998,6 +1392,42 @@ object CssParser { return Triple(w, s, c) } + private fun extractCustomProperties( + properties: String, + inheritedCustomProperties: Map + ): Map { + val result = linkedMapOf() + splitDeclarations(properties).forEach { declaration -> + val parts = declaration.split(':', limit = 2).map { it.trim() } + if (parts.size != 2 || !parts[0].startsWith("--")) return@forEach + val rawValue = parts[1].replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim() + result[parts[0]] = resolveCssVariables(rawValue, inheritedCustomProperties + result) + } + return result + } + + private fun resolveCssVariables(value: String, customProperties: Map): String { + if (!value.contains("var(")) return value + var resolved = value + repeat(8) { + val next = Regex("""var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)""").replace(resolved) { match -> + customProperties[match.groupValues[1]] ?: match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() } ?: "" + } + if (next == resolved) return resolved + resolved = next + } + return resolved + } + + private fun normalizeBreakValue(value: String): String? { + return when (value.lowercase()) { + "always" -> "page" + "avoid", "avoid-page", "page", "left", "right", "recto", "verso" -> value.lowercase() + "auto" -> null + else -> null + } + } + internal fun parseCssDimension( size: String, baseFontSizeSp: Float, @@ -1005,10 +1435,19 @@ object CssParser { containerWidthPx: Int ): Dp { val trimmed = size.trim().lowercase() - if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) { + if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial", "unset", "revert")) { return Dp.Unspecified } if (trimmed == "0" || trimmed == "0px") return 0.dp + if (trimmed.startsWith("calc(") && trimmed.endsWith(")")) { + val px = evaluateCssLengthExpression( + expression = trimmed.removePrefix("calc(").removeSuffix(")"), + baseFontSizeSp = baseFontSizeSp, + density = density, + containerWidthPx = containerWidthPx + ) + if (px != null) return (px / density).dp + } return when { trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified @@ -1038,6 +1477,86 @@ object CssParser { } } + private fun evaluateCssLengthExpression( + expression: String, + baseFontSizeSp: Float, + density: Float, + containerWidthPx: Int + ): Float? { + val tokens = Regex("""(\d*\.?\d+(?:px|dp|em|rem|pt|%)?)|([+\-*/()])""") + .findAll(expression.replace("\\s+".toRegex(), "")) + .map { it.value } + .toList() + if (tokens.isEmpty()) return null + + var index = 0 + var parseExpression: (() -> Float?)? = null + fun parseNumber(token: String): Float? { + return when { + token.endsWith("px") -> token.removeSuffix("px").toFloatOrNull() + token.endsWith("dp") -> token.removeSuffix("dp").toFloatOrNull()?.let { it * density } + token.endsWith("em") -> token.removeSuffix("em").toFloatOrNull()?.let { it * baseFontSizeSp * density } + token.endsWith("rem") -> token.removeSuffix("rem").toFloatOrNull()?.let { it * baseFontSizeSp * density } + token.endsWith("pt") -> token.removeSuffix("pt").toFloatOrNull()?.let { it * 1.333f * density } + token.endsWith("%") -> token.removeSuffix("%").toFloatOrNull()?.let { (it / 100f) * containerWidthPx } + else -> token.toFloatOrNull() + } + } + + fun parseFactor(): Float? { + val token = tokens.getOrNull(index++) ?: return null + return when (token) { + "+" -> parseFactor() + "-" -> parseFactor()?.let { -it } + "(" -> { + val value = parseExpression?.invoke() ?: return null + if (tokens.getOrNull(index) == ")") index++ + value + } + else -> parseNumber(token) + } + } + + fun parseTerm(): Float? { + var value = parseFactor() ?: return null + while (true) { + when (tokens.getOrNull(index)) { + "*" -> { + index++ + value *= parseFactor() ?: return null + } + "/" -> { + index++ + val divisor = parseFactor() ?: return null + if (divisor == 0f) return null + value /= divisor + } + else -> return value + } + } + } + + parseExpression = fun(): Float? { + var value = parseTerm() ?: return null + while (true) { + when (tokens.getOrNull(index)) { + "+" -> { + index++ + value += parseTerm() ?: return null + } + "-" -> { + index++ + value -= parseTerm() ?: return null + } + else -> return value + } + } + } + + val result = parseExpression?.invoke() + return if (result != null && index == tokens.size) result else null + } + internal fun parseCssSizeToDp( size: String, baseFontSizeSp: Float, @@ -1050,9 +1569,10 @@ object CssParser { return if (dim.isSpecified) dim else 0.dp } - internal fun parseColor(colorString: String): Color? { + internal fun parseColor(colorString: String, currentColor: Color = Color.Unspecified): Color? { val sanitized = colorString.trim().lowercase() return when { + sanitized == "currentcolor" -> currentColor.takeIf { it.isSpecified } sanitized.startsWith("#") -> { val hex = sanitized.substring(1) val colorLong = hex.toLongOrNull(16) ?: return null @@ -1063,6 +1583,13 @@ object CssParser { val b = colorLong and 0x00F Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255) } + 4 -> { + val r = (colorLong and 0xF000) shr 12 + val g = (colorLong and 0x0F00) shr 8 + val b = (colorLong and 0x00F0) shr 4 + val a = colorLong and 0x000F + Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), (a * 17).toInt()) + } 6 -> Color( ((colorLong shr 16) and 0xFF).toInt(), ((colorLong shr 8) and 0xFF).toInt(), @@ -1070,36 +1597,69 @@ object CssParser { 255 ) // #RRGGBB 8 -> Color( + ((colorLong shr 24) and 0xFF).toInt(), ((colorLong shr 16) and 0xFF).toInt(), ((colorLong shr 8) and 0xFF).toInt(), - (colorLong and 0xFF).toInt(), - ((colorLong shr 24) and 0xFF).toInt() - ) // #AARRGGBB + (colorLong and 0xFF).toInt() + ) // CSS #RRGGBBAA else -> null } } sanitized.startsWith("rgb") -> { - val isRgba = sanitized.startsWith("rgba") val valuesString = sanitized.substringAfter('(').substringBefore(')') - val values = valuesString.split(',').map { it.trim() } + val alphaParts = valuesString.split('/').map { it.trim() } + val values = if (alphaParts.first().contains(',')) { + alphaParts.first().split(',').map { it.trim() } + } else { + splitCssTokens(alphaParts.first()) + } if (values.size < 3) return null - val r = values[0].toIntOrNull() ?: 0 - val g = values[1].toIntOrNull() ?: 0 - val b = values[2].toIntOrNull() ?: 0 - val a = if (isRgba && values.size == 4) (values[3].toFloatOrNull() ?: 1f) else 1f + fun channel(part: String): Int { + return if (part.endsWith("%")) { + (((part.removeSuffix("%").toFloatOrNull() ?: 0f) / 100f) * 255f).roundToInt() + } else { + part.toFloatOrNull()?.roundToInt() ?: 0 + }.coerceIn(0, 255) + } + fun alpha(part: String?): Float { + if (part.isNullOrBlank()) return 1f + return if (part.endsWith("%")) { + ((part.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f) + } else { + part.toFloatOrNull() ?: 1f + }.coerceIn(0f, 1f) + } + + val r = channel(values[0]) + val g = channel(values[1]) + val b = channel(values[2]) + val a = alpha(alphaParts.getOrNull(1) ?: values.getOrNull(3)) Color(r, g, b, (a * 255).roundToInt()) } + sanitized.startsWith("hsl") -> parseHslColor(sanitized) else -> when(sanitized) { "black" -> Color.Black "white" -> Color.White "red" -> Color.Red - "green" -> Color.Green + "green" -> Color(0, 128, 0) + "lime" -> Color.Green "blue" -> Color.Blue "gray", "grey" -> Color.Gray + "silver" -> Color(192, 192, 192) + "maroon" -> Color(128, 0, 0) + "olive" -> Color(128, 128, 0) + "purple" -> Color(128, 0, 128) + "teal" -> Color(0, 128, 128) + "navy" -> Color(0, 0, 128) + "orange" -> Color(255, 165, 0) + "brown" -> Color(165, 42, 42) + "pink" -> Color(255, 192, 203) "cyan" -> Color.Cyan + "aqua" -> Color.Cyan + "fuchsia" -> Color.Magenta "magenta" -> Color.Magenta "yellow" -> Color.Yellow "transparent" -> Color.Transparent @@ -1108,8 +1668,46 @@ object CssParser { } } + private fun parseHslColor(value: String): Color? { + val valuesString = value.substringAfter('(').substringBefore(')') + val alphaParts = valuesString.split('/').map { it.trim() } + val values = if (alphaParts.first().contains(',')) { + alphaParts.first().split(',').map { it.trim() } + } else { + splitCssTokens(alphaParts.first()) + } + if (values.size < 3) return null + val hue = values[0].removeSuffix("deg").toFloatOrNull() ?: return null + val saturation = values[1].removeSuffix("%").toFloatOrNull()?.div(100f) ?: return null + val lightness = values[2].removeSuffix("%").toFloatOrNull()?.div(100f) ?: return null + val alpha = alphaParts.getOrNull(1)?.let { + if (it.endsWith("%")) (it.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f else it.toFloatOrNull() ?: 1f + } ?: values.getOrNull(3)?.let { + if (it.endsWith("%")) (it.removeSuffix("%").toFloatOrNull() ?: 100f) / 100f else it.toFloatOrNull() ?: 1f + } ?: 1f + + val c = (1f - kotlin.math.abs(2f * lightness - 1f)) * saturation + val x = c * (1f - kotlin.math.abs((hue / 60f) % 2f - 1f)) + val m = lightness - c / 2f + val normalizedHue = ((hue % 360f) + 360f) % 360f + val (r1, g1, b1) = when { + normalizedHue < 60f -> Triple(c, x, 0f) + normalizedHue < 120f -> Triple(x, c, 0f) + normalizedHue < 180f -> Triple(0f, c, x) + normalizedHue < 240f -> Triple(0f, x, c) + normalizedHue < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + return Color( + ((r1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((g1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((b1 + m) * 255f).roundToInt().coerceIn(0, 255), + (alpha.coerceIn(0f, 1f) * 255f).roundToInt() + ) + } + private fun parseBoxBorders(value: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int): BoxBorders { - val parts = value.split(' ').map { it.trim() }.filter { it.isNotEmpty() } + val parts = splitCssTokens(value) val dps = parts.map { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } return when (dps.size) { 1 -> BoxBorders(top = dps[0], right = dps[0], bottom = dps[0], left = dps[0]) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt index b2142fc..ef21cf2 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt @@ -78,7 +78,20 @@ data class BlockStyle( @ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp, @ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp, @ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp, - @ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp + @ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp, + @ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified, + @ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified, + @ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified, + @ProtoNumber(38) val overflow: String? = null, + @ProtoNumber(39) val breakBefore: String? = null, + @ProtoNumber(40) val breakAfter: String? = null, + @ProtoNumber(41) val breakInside: String? = null, + @ProtoNumber(42) val widows: Int = 2, + @ProtoNumber(43) val orphans: Int = 2, + @ProtoNumber(44) val visibility: String? = null, + @ProtoNumber(45) val objectFit: String? = null, + @ProtoNumber(46) val objectPosition: String? = null, + @ProtoNumber(47) val backgroundImage: String? = null ) { fun merge(other: BlockStyle): BlockStyle { return BlockStyle( @@ -125,7 +138,20 @@ data class BlockStyle( horizontalAlign = other.horizontalAlign ?: this.horizontalAlign, filter = other.filter ?: this.filter, borderCollapse = other.borderCollapse ?: this.borderCollapse, - borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing + borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing, + minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth, + minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight, + maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight, + overflow = other.overflow ?: this.overflow, + breakBefore = other.breakBefore ?: this.breakBefore, + breakAfter = other.breakAfter ?: this.breakAfter, + breakInside = other.breakInside ?: this.breakInside, + widows = if (other.widows != 2) other.widows else this.widows, + orphans = if (other.orphans != 2) other.orphans else this.orphans, + visibility = other.visibility ?: this.visibility, + objectFit = other.objectFit ?: this.objectFit, + objectPosition = other.objectPosition ?: this.objectPosition, + backgroundImage = other.backgroundImage ?: this.backgroundImage ) } } @@ -307,7 +333,10 @@ data class CssStyle( @ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified, @ProtoNumber(14) val textDecorationStyle: String? = null, @ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified, - @ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified + @ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified, + @ProtoNumber(17) val whiteSpace: String? = null, + @ProtoNumber(18) val verticalAlign: String? = null, + @ProtoNumber(19) val customProperties: Map = emptyMap() ) { fun merge(other: CssStyle): CssStyle { return CssStyle( @@ -326,7 +355,10 @@ data class CssStyle( wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing, textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle, textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor, - textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset + textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset, + whiteSpace = other.whiteSpace ?: this.whiteSpace, + verticalAlign = other.verticalAlign ?: this.verticalAlign, + customProperties = this.customProperties + other.customProperties ) } } @@ -340,7 +372,9 @@ data class CssSelector( @Serializable data class CssRule( @ProtoNumber(1) val selector: CssSelector, - @ProtoNumber(2) val style: CssStyle + @ProtoNumber(2) val style: CssStyle, + @ProtoNumber(3) val pseudoElement: String? = null, + @ProtoNumber(4) val sourceOrder: Int = 0 ) @Serializable @@ -371,7 +405,8 @@ data class OptimizedCssRules( @ProtoNumber(1) val byTag: Map> = emptyMap(), @ProtoNumber(2) val byClass: Map> = emptyMap(), @ProtoNumber(3) val byId: Map> = emptyMap(), - @ProtoNumber(4) val otherComplex: List = emptyList() + @ProtoNumber(4) val otherComplex: List = emptyList(), + @ProtoNumber(5) val allRules: List = emptyList() ) { fun merge(other: OptimizedCssRules): OptimizedCssRules { fun mergeMap( @@ -397,16 +432,17 @@ data class OptimizedCssRules( byTag = mergeMap(this.byTag, other.byTag), byClass = mergeMap(this.byClass, other.byClass), byId = mergeMap(this.byId, other.byId), - otherComplex = this.otherComplex + other.otherComplex + otherComplex = this.otherComplex + other.otherComplex, + allRules = this.toFlatList() + other.toFlatList() ) } fun toFlatList(): List { - return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex + return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex } } } data class OptimizedCssParseResult( val rules: OptimizedCssRules, val fontFaces: List -) \ No newline at end of file +) 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 6aa87d2..47e8e79 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt @@ -76,6 +76,7 @@ sealed interface AppAction { data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction data class CustomAppThemeDeleted(val themeId: String) : AppAction + data class CustomReaderThemesChanged(val themes: List) : AppAction data class SyncEnabledChanged(val enabled: Boolean) : AppAction data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction data class TabsEnabledChanged(val enabled: Boolean) : 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 f844c98..eecf764 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt @@ -272,6 +272,7 @@ data class SharedReaderScreenState( val appSeedColor: Color? = null, val appFontPreference: AppFontPreference = AppFontPreference.System, val customAppThemes: List = emptyList(), + val customReaderThemes: List = emptyList(), val readerDefaultSettings: ReaderSettings = ReaderSettings(), val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), val allTags: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CloudSyncDecisions.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CloudSyncDecisions.kt new file mode 100644 index 0000000..47c3e55 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CloudSyncDecisions.kt @@ -0,0 +1,102 @@ +package com.aryan.reader.shared + +enum class SharedCloudBookMetadataWinner { + LOCAL, + REMOTE, + SAME +} + +fun sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long +): SharedCloudBookMetadataWinner { + val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE + return when { + localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL + remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE + else -> SharedCloudBookMetadataWinner.SAME + } +} + +fun sharedCloudBookMetadataWinner( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): SharedCloudBookMetadataWinner { + val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp) + return when { + localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL + remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE + else -> SharedCloudBookMetadataWinner.SAME + } +} + +fun shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long +): Boolean { + return sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp + ) == SharedCloudBookMetadataWinner.REMOTE +} + +fun shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp: Long, + remoteModifiedTimestamp: Long +): Boolean { + return sharedCloudBookReadingMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp + ) == SharedCloudBookMetadataWinner.LOCAL +} + +fun shouldApplyRemoteCloudBookUpdate( + localModifiedTimestamp: Long?, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): Boolean { + return sharedCloudBookMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp, + localSidecarModifiedTimestamp = localSidecarModifiedTimestamp + ) == SharedCloudBookMetadataWinner.REMOTE +} + +fun shouldUploadLocalCloudBookUpdate( + localModifiedTimestamp: Long, + remoteModifiedTimestamp: Long, + localSidecarModifiedTimestamp: Long = 0L +): Boolean { + return sharedCloudBookMetadataWinner( + localModifiedTimestamp = localModifiedTimestamp, + remoteModifiedTimestamp = remoteModifiedTimestamp, + localSidecarModifiedTimestamp = localSidecarModifiedTimestamp + ) == SharedCloudBookMetadataWinner.LOCAL +} + +fun shouldDownloadRemoteCloudBookContent( + localFileAvailable: Boolean, + localContentModifiedTimestamp: Long, + remoteContentModifiedTimestamp: Long, + remoteDeleted: Boolean = false +): Boolean { + return !remoteDeleted && + remoteContentModifiedTimestamp > 0L && + (!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp) +} + +fun shouldUploadLocalCloudBookContent( + localFileAvailable: Boolean, + localContentModifiedTimestamp: Long, + remoteContentModifiedTimestamp: Long? +): Boolean { + return localFileAvailable && + localContentModifiedTimestamp > 0L && + localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L) +} + +fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? { + val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null + return "$bookId.$extension" +} 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 967e276..3a00dee 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt @@ -89,6 +89,10 @@ object SharedFileCapabilities { "application/x-rar-compressed", "application/x-cb7", "application/x-7z-compressed", + "application/vnd.comicbook+tar", + "application/x-cbt", + "application/x-tar", + "application/tar", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", @@ -166,6 +170,13 @@ object SharedFileCapabilities { androidSurface = ReaderFeatureSurface.PDF_VIEWER, desktopSurface = ReaderFeatureSurface.PDF_VIEWER ), + FileTypeCapability( + type = FileType.CBT, + displayName = "CBT", + extensions = setOf("cbt"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), FileTypeCapability( type = FileType.DOCX, displayName = "DOCX", @@ -211,11 +222,13 @@ object SharedFileCapabilities { FileType.CBZ to "application/zip", FileType.CBR to "application/zip", FileType.CB7 to "application/zip", + FileType.CBT to "application/x-tar", 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 comicArchiveTypes: Set = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT) val knownFileTypes: Set = all.mapTo(mutableSetOf()) { it.type } fun capabilityFor(type: FileType): FileTypeCapability? { @@ -234,6 +247,10 @@ object SharedFileCapabilities { return mimeTypesByType[type] } + fun isComicArchive(type: FileType): Boolean { + return type in comicArchiveTypes + } + fun fileTypeForName(fileName: String): FileType { return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN } @@ -267,6 +284,9 @@ object SharedFileCapabilities { "application/x-cb7", "application/x-7z-compressed" -> { if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null } + "application/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> { + if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT else null + } "application/pdf" -> FileType.PDF "application/epub+zip" -> FileType.EPUB "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 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 9535879..79f4cc0 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt @@ -5,7 +5,7 @@ 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, PPTX, UNKNOWN + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN } val PDF_VIEWER_FILE_TYPES: Set @@ -67,7 +67,8 @@ data class SyncedFolder( val uriString: String, val name: String, val lastScanTime: Long, - val allowedFileTypes: Set = SharedFileCapabilities.knownFileTypes + val allowedFileTypes: Set = SharedFileCapabilities.knownFileTypes, + val localSyncEnabled: Boolean = true ) data class BookItem( @@ -99,7 +100,8 @@ data class BookItem( val readerSettings: ReaderSettings? = null, val readerBookmarks: List = emptyList(), val readerHighlights: List = emptyList(), - val pdfReaderViewport: SharedPdfReaderViewport? = null + val pdfReaderViewport: SharedPdfReaderViewport? = null, + val readingPositionModifiedTimestamp: Long = 0L ) data class Shelf( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt index 3bae388..12afe1f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt @@ -99,6 +99,46 @@ object SharedLibraryEditor { ) } + fun createShelfWithBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + name: String, + bookIds: Iterable, + clearSelection: Boolean = true, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val trimmed = cleanShelfName(name) ?: return null + val selectedBooks = cleanBookIds(bookIds) + val shelfId = "shelf_$nowMillis" + val newRefs = selectedBooks.map { bookId -> + BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) + } + return SharedLibraryMutationResult( + state = state.copy( + selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds, + bannerMessage = if (selectedBooks.isEmpty()) { + BannerMessage.string( + "banner_shelf_created", + "Created shelf \"%1\$s\".", + trimmed + ) + } else { + BannerMessage.quantity( + "banner_shelf_created_with_books", + selectedBooks.size, + "Created shelf \"%1\$s\" with %2\$d book.", + "Created shelf \"%1\$s\" with %2\$d books.", + trimmed, + selectedBooks.size + ) + } + ), + shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed), + shelfRefs = shelfRefs + newRefs + ) + } + fun createSmartShelf( state: SharedReaderScreenState, shelfRecords: List, @@ -239,25 +279,73 @@ object SharedLibraryEditor { shelfId: String, nowMillis: Long = currentTimestamp() ): SharedLibraryMutationResult? { - val selected = state.selectedBookIds - if (selected.isEmpty()) return null - val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } - val additions = selected.mapNotNull { bookId -> - if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) - } + return addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = state.selectedBookIds, + shelfIds = listOf(shelfId), + clearSelection = true, + nowMillis = nowMillis, + bannerName = "banner_books_added_to_shelf", + singularMessage = "%1\$d book added to shelf.", + pluralMessage = "%1\$d books added to shelf." + ) + } + + fun addBooksToShelves( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + bookIds: Iterable, + shelfIds: Iterable, + clearSelection: Boolean = true, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + return addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = bookIds, + shelfIds = shelfIds, + clearSelection = clearSelection, + nowMillis = nowMillis, + bannerName = "banner_books_added_to_shelves", + singularMessage = "%1\$d shelf entry added.", + pluralMessage = "%1\$d shelf entries added." + ) + } + + fun replaceShelfBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + shelfId: String, + bookIds: Iterable, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val cleanShelfId = shelfId.trim() + if (!canMutateShelf(cleanShelfId)) return null + val selectedBooks = cleanBookIds(bookIds) + val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name + ?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name + ?: cleanShelfId return SharedLibraryMutationResult( state = state.copy( - selectedBookIds = emptySet(), bannerMessage = BannerMessage.quantity( - "banner_books_added_to_shelf", - additions.size, - "%1\$d book added to shelf.", - "%1\$d books added to shelf.", - additions.size + "banner_shelf_books_updated", + selectedBooks.size, + "Updated \"%1\$s\" with %2\$d book.", + "Updated \"%1\$s\" with %2\$d books.", + shelfName, + selectedBooks.size ) ), shelfRecords = shelfRecords, - shelfRefs = shelfRefs + additions + shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } + + selectedBooks.map { bookId -> + BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis) + } ) } @@ -325,6 +413,51 @@ object SharedLibraryEditor { shelfRefs = shelfRefs ) } + + private fun addBooksToShelves( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + bookIds: Iterable, + shelfIds: Iterable, + clearSelection: Boolean, + nowMillis: Long, + bannerName: String, + singularMessage: String, + pluralMessage: String + ): SharedLibraryMutationResult? { + val selectedBooks = cleanBookIds(bookIds) + val targetShelfIds = shelfIds + .map { it.trim() } + .filter { canMutateShelf(it) } + .distinct() + if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null + + val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } + val additions = targetShelfIds.flatMap { shelfId -> + selectedBooks.mapNotNull { bookId -> + if (!existing.add(bookId to shelfId)) { + null + } else { + BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) + } + } + } + return SharedLibraryMutationResult( + state = state.copy( + selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds, + bannerMessage = BannerMessage.quantity( + bannerName, + additions.size, + singularMessage, + pluralMessage, + additions.size + ) + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + additions + ) + } } fun parseTagList(input: String, knownTags: List, nowMillis: Long = currentTimestamp()): List { 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 ccb7976..97a1124 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt @@ -141,6 +141,13 @@ data class SharedFolderBookMetadata( seriesIndex = existing?.seriesIndex, lastPageIndex = lastPage, readerPosition = parsedReaderPosition ?: existing?.readerPosition, + readingPositionModifiedTimestamp = if ( + parsedReaderPosition != null || lastPage != null || progressPercentage > 0f + ) { + metadataTimestamp + } else { + existing?.readingPositionModifiedTimestamp ?: 0L + }, readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(), readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty() ) @@ -152,6 +159,9 @@ data class SharedFolderBookMetadata( chapterIndex = lastChapterIndex, cfi = lastPositionCfi, pageIndex = lastPage + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset ) } @@ -272,6 +282,15 @@ object LocalFolderSyncEngine { nowMillis: Long = currentTimestamp(), metadataOnly: Boolean = false ): LocalFolderSyncResult { + if (!folder.localSyncEnabled) { + return LocalFolderSyncResult( + state = state, + idMigrations = emptyMap(), + removedBookIds = emptySet(), + stats = LocalFolderSyncStats() + ) + } + val folderRoot = folder.uriString val allowedTypes = folder.allowedFileTypes val booksById = linkedMapOf() @@ -439,16 +458,7 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { !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 - } - } + val positionCfi = position?.toStablePositionCfi() return SharedFolderBookMetadata( bookId = id, @@ -463,8 +473,8 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { isRecent = isRecent, lastModifiedTimestamp = localFolderModifiedTimestamp(), bookmarksJson = bookmarksJson, - locatorBlockIndex = null, - locatorCharOffset = null, + locatorBlockIndex = position?.blockIndex, + locatorCharOffset = position?.charOffset, customName = null, highlightsJson = highlightsJson, seriesName = null, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt index 9b30bc4..0957299 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt @@ -42,11 +42,15 @@ data class ReaderLocator( val pageIndex: Int? = null, val startOffset: Int? = null, val endOffset: Int? = null, + val blockIndex: Int? = null, + val charOffset: Int? = null, val textQuote: String? = null, val cfi: String? = null ) { val hasTextRange: Boolean get() = startOffset != null && endOffset != null && endOffset >= startOffset + val hasBlockPosition: Boolean + get() = blockIndex != null && charOffset != null fun withFallbacks( chapterIndex: Int? = null, @@ -55,6 +59,8 @@ data class ReaderLocator( pageIndex: Int? = null, startOffset: Int? = null, endOffset: Int? = null, + blockIndex: Int? = null, + charOffset: Int? = null, textQuote: String? = null, cfi: String? = null ): ReaderLocator { @@ -65,6 +71,8 @@ data class ReaderLocator( pageIndex = this.pageIndex ?: pageIndex, startOffset = this.startOffset ?: startOffset, endOffset = this.endOffset ?: endOffset, + blockIndex = this.blockIndex ?: blockIndex, + charOffset = this.charOffset ?: charOffset, textQuote = this.textQuote ?: textQuote, cfi = this.cfi ?: cfi ) @@ -74,6 +82,10 @@ data class ReaderLocator( val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex if (!sameChapter) return false + if (hasBlockPosition && other.hasBlockPosition) { + return blockIndex == other.blockIndex && charOffset == other.charOffset + } + if (hasTextRange && other.hasTextRange) { return startOffset == other.startOffset && endOffset == other.endOffset } @@ -92,21 +104,40 @@ data class ReaderLocator( pageIndex: Int? = null, textQuote: String? = null ): ReaderLocator { - val desktopParts = cfi + val stableCfi = cfi?.toStableReaderPositionCfi() + val desktopParts = stableCfi ?.takeIf { it.startsWith("desktop:") } ?.split(':') .orEmpty() val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull() val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull() val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull() + val androidLocatorParts = stableCfi + ?.takeIf { it.startsWith("android-locator:") } + ?.split(':') + .orEmpty() + val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull() + val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull() + val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull() + ?.takeIf { it >= 0 } + val parsedAndroidEndOffset = parsedCharOffset + ?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } } val hasOffsetRange = desktopParts.size == 4 && possibleStartOffset != null && possibleEndOffset != null && possibleStartOffset >= 0 && possibleEndOffset >= possibleStartOffset && possibleEndOffset - possibleStartOffset <= 100_000 - val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null - val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null + val parsedStartOffset = when { + hasOffsetRange -> possibleStartOffset + parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset + else -> null + } + val parsedEndOffset = when { + hasOffsetRange -> possibleEndOffset + parsedBlockIndex != null -> parsedAndroidEndOffset + else -> null + } val parsedPageIndex = when { pageIndex != null -> pageIndex desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) -> @@ -114,23 +145,56 @@ data class ReaderLocator( else -> null } return ReaderLocator( - chapterIndex = chapterIndex ?: parsedChapterIndex, + chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex, pageIndex = parsedPageIndex, startOffset = parsedStartOffset, endOffset = parsedEndOffset, + blockIndex = parsedBlockIndex, + charOffset = parsedCharOffset, textQuote = textQuote, - cfi = cfi + cfi = stableCfi ?: cfi ) } } } +fun String.toStableReaderPositionCfi(): String { + val trimmed = trim() + if (!trimmed.startsWith("desktop-scroll:")) return trimmed + return trimmed + .split(':', limit = 4) + .getOrNull(3) + ?.takeIf { it.isNotBlank() } + ?: trimmed +} + +fun ReaderLocator.toStablePositionCfi(): String? { + cfi + ?.toStableReaderPositionCfi() + ?.takeIf { it.isNotBlank() } + ?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") } + ?.let { return it } + + val chapter = chapterIndex + val start = startOffset + val end = endOffset ?: start + return when { + chapter != null && blockIndex != null && charOffset != null -> + "android-locator:$chapter:$blockIndex:$charOffset" + chapter != null && start != null && end != null -> + "desktop:$chapter:$start:$end" + chapter != null && pageIndex != null -> + "desktop:$chapter:$pageIndex" + else -> null + } +} + data class ReaderHighlightPalette( val colors: List = defaultColors ) { fun sanitized(): ReaderHighlightPalette { - val distinct = colors.distinct().filter { it in HighlightColor.entries } - return copy(colors = distinct.ifEmpty { defaultColors }) + val knownColors = colors.filter { it in HighlightColor.entries } + return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors) } fun contains(color: HighlightColor): Boolean { @@ -147,14 +211,13 @@ data class ReaderHighlightPalette( } companion object { + const val PaletteSize: Int = 4 val defaultColors: List get() = listOf( HighlightColor.YELLOW, HighlightColor.GREEN, HighlightColor.BLUE, - HighlightColor.RED, - HighlightColor.PURPLE, - HighlightColor.ORANGE + HighlightColor.RED ) } } 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 2fb43be..5d282a9 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt @@ -238,6 +238,8 @@ object EpubAnnotationSerializer { pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } startOffset?.let { put("startOffset", JsonPrimitive(it)) } endOffset?.let { put("endOffset", JsonPrimitive(it)) } + blockIndex?.let { put("blockIndex", JsonPrimitive(it)) } + charOffset?.let { put("charOffset", JsonPrimitive(it)) } textQuote?.let { put("textQuote", JsonPrimitive(it)) } cfi?.let { put("cfi", JsonPrimitive(it)) } } @@ -253,6 +255,8 @@ object EpubAnnotationSerializer { pageIndex = obj.int("pageIndex"), startOffset = obj.int("startOffset"), endOffset = obj.int("endOffset"), + blockIndex = obj.int("blockIndex"), + charOffset = obj.int("charOffset"), textQuote = obj.string("textQuote"), cfi = obj.string("cfi") ) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt index 578ec37..e495d60 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt @@ -101,13 +101,29 @@ data class ReaderTheme( val isCustom: Boolean = false ) -val BuiltInReaderThemes = listOf( - ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false), +fun List.sanitizeCustomReaderThemes(): List { + val seenIds = mutableSetOf() + return asReversed() + .filter { theme -> + theme.isCustom && + theme.id.isNotBlank() && + theme.name.isNotBlank() && + theme.backgroundColor.isSpecified && + theme.textColor.isSpecified && + seenIds.add(theme.id) + } + .asReversed() +} + +private val StandardReaderSolidThemes = listOf( ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), - ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), + ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true) +) + +private val StandardReaderTexturedThemes = listOf( ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), @@ -116,21 +132,16 @@ val BuiltInReaderThemes = listOf( ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) ) +val BuiltInReaderThemes = listOf( + ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false) +) + StandardReaderSolidThemes + StandardReaderTexturedThemes + val BuiltInPdfReaderThemes = listOf( ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), - ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true), - ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), - ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), - ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), - ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), - ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), - ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), - ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), - ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), - ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), - ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), - ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) -) + ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true) +) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme -> + theme.copy(id = "pdf_${theme.id}") +} fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings { val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt() @@ -169,6 +180,59 @@ fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): Reade ) } +fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings { + val defaults = ReaderSettings() + return copy( + fontSize = defaults.fontSize, + lineSpacing = defaults.lineSpacing, + margin = defaults.margin, + horizontalMargin = defaults.horizontalMargin, + verticalMargin = defaults.verticalMargin, + textAlign = defaults.textAlign, + pageWidth = defaults.pageWidth, + fontFamily = defaults.fontFamily, + paragraphSpacing = defaults.paragraphSpacing, + imageScale = defaults.imageScale, + customFontPath = defaults.customFontPath + ) +} + +fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings { + val nextHorizontal = horizontalMarginPx.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + val currentVertical = resolvedVerticalMargin.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + return copy( + margin = max(nextHorizontal, currentVertical), + horizontalMargin = nextHorizontal, + verticalMargin = currentVertical + ) +} + +fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings { + val currentHorizontal = resolvedHorizontalMargin.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + val nextVertical = verticalMarginPx.coerceIn( + ReaderAppearanceDefaults.minMarginPx, + ReaderAppearanceDefaults.maxMarginPx + ) + return copy( + margin = max(currentHorizontal, nextVertical), + horizontalMargin = currentHorizontal, + verticalMargin = nextVertical + ) +} + +fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean { + return readingMode == ReaderReadingMode.PAGINATED +} + fun readerThemeById(themeId: String?): ReaderTheme? { return BuiltInReaderThemes.firstOrNull { it.id == themeId } } 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 be64849..e929072 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt @@ -10,11 +10,13 @@ import com.aryan.reader.shared.reader.ReaderPage import com.aryan.reader.shared.reader.ReaderSessionState import com.aryan.reader.shared.reader.SharedEpubBook import com.aryan.reader.shared.reader.SharedEpubChapter +import com.aryan.reader.shared.reader.logSharedReaderDiagnostic const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview" const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL" const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede" const val READER_TTS_CHUNK_MAX_LENGTH = 250 +private const val ReaderTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" data class ReaderCloudTtsVoice( val id: String, @@ -325,15 +327,42 @@ object ReaderTtsPlanner { val anchor = session.navigationLocator val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) } + val syntheticDesktopAnchor = anchor?.isSyntheticDesktopTtsAnchor() == true 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++) + logReaderTtsStartTrace { + "event=planner_from_here_start pageIndex=$pageIndex pages=${pages.size} chunks=${chunks.size} " + + "syntheticDesktop=$syntheticDesktopAnchor " + + "anchor=${anchor.readerTtsLocatorSummary()} first=${chunks.firstOrNull().readerTtsChunkSummary()} " + + "second=${chunks.getOrNull(1).readerTtsChunkSummary()}" } + if (chapterIndex == null && startOffset == null) return chunks + val target = anchor?.toTtsChunkTarget() + val startChunkIndex = findReaderTtsChunkStartIndex(chunks, target) + ?: chunks.indexOfFirst { it.isOnOrAfterLocator(chapterIndex, startOffset) }.takeIf { it >= 0 } + ?: run { + logReaderTtsStartTrace { + "event=planner_from_here_empty reason=no_start_chunk target=${target.readerTtsTargetSummary()} " + + "anchor=${anchor.readerTtsLocatorSummary()} chunks=${chunks.size}" + } + return emptyList() + } + val initialChunk = anchor?.let { chunks[startChunkIndex].sliceFromLocator(it) } + val sessionChunks = if (initialChunk == null) { + chunks.drop(startChunkIndex + 1) + } else { + chunks.withInitialChunkOverride(startChunkIndex, initialChunk).drop(startChunkIndex) + } + logReaderTtsStartTrace { + "event=planner_from_here_result target=${target.readerTtsTargetSummary()} startChunkIndex=$startChunkIndex " + + "sourceChunk=${chunks.getOrNull(startChunkIndex).readerTtsChunkSummary()} " + + "initialChunk=${initialChunk.readerTtsChunkSummary()} resultChunks=${sessionChunks.size} " + + "resultFirst=${sessionChunks.firstOrNull().readerTtsChunkSummary()}" + } + return sessionChunks + .filter { it.text.isNotBlank() } + .mapIndexed { index, chunk -> chunk.copy(index = index) } } fun chunksForText( @@ -401,28 +430,51 @@ object ReaderTtsPlanner { } } - private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? { + private fun ReaderTtsChunk.isOnOrAfterLocator(chapterIndex: Int?, startOffset: Int?): Boolean { if (chapterIndex != null) { - if (this.chapterIndex < chapterIndex) return null - if (this.chapterIndex > chapterIndex) return this + if (this.chapterIndex < chapterIndex) return false + if (this.chapterIndex > chapterIndex) return true } - val anchorOffset = startOffset ?: return this - if (endOffset <= anchorOffset) return null - if (anchorOffset <= this.startOffset) return this - return trimStartTo(anchorOffset) + val anchorOffset = startOffset ?: return true + return endOffset > 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) + private fun ReaderTtsChunk.sliceFromLocator(locator: ReaderLocator): ReaderTtsChunk? { + if (locator.chapterIndex != null && locator.chapterIndex != chapterIndex) return this + val sourceOffset = locator.startOffset ?: return this + val rawDrop = (sourceOffset - startOffset).coerceIn(0, text.length) + val drop = rawDrop + if (drop <= 0) { + logReaderTtsStartTrace { + "event=planner_slice_keep reason=drop_at_start rawDrop=$rawDrop " + + "locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return this + } + if (drop >= text.length) { + logReaderTtsStartTrace { + "event=planner_slice_skip reason=drop_past_end rawDrop=$rawDrop " + + "chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return null + } + val remaining = text.drop(drop) val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() } - if (leadingWhitespace < 0) return null + if (leadingWhitespace < 0) { + logReaderTtsStartTrace { + "event=planner_slice_skip reason=blank_after_drop rawDrop=$rawDrop " + + "chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}" + } + return null + } val nextText = remaining.drop(leadingWhitespace) if (nextText.isBlank()) return null - val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset) + val nextStartOffset = (sourceOffset + leadingWhitespace).coerceAtMost(endOffset) + logReaderTtsStartTrace { + "event=planner_slice_result rawDrop=$rawDrop chosenDrop=$drop " + + "leadingWhitespace=$leadingWhitespace nextStart=$nextStartOffset locator=${locator.readerTtsLocatorSummary()} " + + "chunk=${readerTtsChunkSummary()} nextText=\"${nextText.readerTtsLogPreview()}\"" + } return copy( text = nextText, spokenText = nextText, @@ -545,7 +597,7 @@ object ReaderTtsPlanner { chunks += ReaderTtsTextRange( text = currentText.toString(), start = currentStart, - end = currentStart + currentText.length + end = currentEnd ) } currentText = StringBuilder() @@ -554,19 +606,25 @@ object ReaderTtsPlanner { } for (sentence in sentenceRanges) { + val appendText = if (currentText.isEmpty()) { + sentence.text + } else { + val gapStart = (currentEnd - sourceStart).coerceIn(0, source.length) + val gapEnd = (sentence.end - sourceStart).coerceIn(gapStart, source.length) + source.substring(gapStart, gapEnd) + } if (sentence.text.length > maxLength) { flushCurrent() chunks += sentence continue } - if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) { + if (currentText.isNotEmpty() && currentText.length + appendText.length > maxLength) { flushCurrent() currentText.append(sentence.text) currentStart = sentence.start currentEnd = sentence.end } else { - if (currentText.isNotEmpty()) currentText.append(" ") - currentText.append(sentence.text) + currentText.append(appendText) if (currentStart < 0) currentStart = sentence.start currentEnd = sentence.end } @@ -612,6 +670,162 @@ object ReaderTtsPlanner { val start: Int, val end: Int ) + + private data class ReaderTtsChunkTarget( + val text: String, + val sourceCfi: String?, + val startOffset: Int + ) + + private fun ReaderLocator.toTtsChunkTarget(): ReaderTtsChunkTarget? { + val offset = startOffset ?: return null + val sourceCfi = cfi + ?.readerTtsSourceCfiBase() + ?.takeIf { it.startsWith("/") } + return ReaderTtsChunkTarget( + text = textQuote.orEmpty(), + sourceCfi = sourceCfi, + startOffset = offset + ) + } + + private fun ReaderLocator.isSyntheticDesktopTtsAnchor(): Boolean { + val value = cfi.orEmpty() + return value.startsWith("desktop:") || + value.startsWith("desktop-scroll:") || + value.startsWith("desktop-scroll-page:") + } + + private fun findReaderTtsChunkStartIndex( + chunks: List, + target: ReaderTtsChunkTarget? + ): Int? { + if (target == null) return null + + val exactIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + it.startOffset == target.startOffset && + it.text.normalizedReaderTtsText() == target.text.normalizedReaderTtsText() + } + if (exactIndex >= 0) return exactIndex + + val sourceAndOffsetIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + target.startOffset >= it.startOffset && + target.startOffset < it.endOffset + } + if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex + + val sourceAndTextIndex = chunks.indexOfFirst { + readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + readerTtsTextMatches(it.text, target.text) + } + if (sourceAndTextIndex >= 0) return sourceAndTextIndex + + val sourceNearestOffsetIndex = chunks + .mapIndexedNotNull { index, chunk -> + if (readerSameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) { + index to kotlin.math.abs(chunk.startOffset - target.startOffset) + } else { + null + } + } + .minByOrNull { it.second } + ?.first + if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex + + return findUniqueReaderTtsTextMatch(chunks, target.text) + } + + private fun List.withInitialChunkOverride( + startChunkIndex: Int, + initialChunk: ReaderTtsChunk? + ): List { + if (initialChunk == null || startChunkIndex !in indices) return this + val existing = this[startChunkIndex] + if ( + existing.text == initialChunk.text && + existing.sourceCfi == initialChunk.sourceCfi && + existing.startOffset == initialChunk.startOffset + ) { + return this + } + return toMutableList().also { it[startChunkIndex] = initialChunk } + } + + private fun readerSameTtsChunkSource(first: String?, second: String?): Boolean { + val firstSource = first.orEmpty() + val secondSource = second.orEmpty() + if (firstSource.isBlank() || secondSource.isBlank()) return firstSource == secondSource + val firstPath = firstSource.readerTtsSourceCfiBase() + val secondPath = secondSource.readerTtsSourceCfiBase() + return firstPath == secondPath || + readerTtsCfiPathContains(firstPath, secondPath) || + readerTtsCfiPathContains(secondPath, firstPath) + } + + private fun readerTtsCfiPathContains(parentPath: String, childPath: String): Boolean { + if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false + val parentParts = parentPath.split('/').filter { it.isNotEmpty() } + val childParts = childPath.split('/').filter { it.isNotEmpty() } + return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts + } + + private fun readerTtsTextMatches(first: String, second: String): Boolean { + val firstNormalized = first.normalizedReaderTtsText() + val secondNormalized = second.normalizedReaderTtsText() + if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false + return firstNormalized == secondNormalized || + firstNormalized.startsWith(secondNormalized) || + secondNormalized.startsWith(firstNormalized) + } + + private fun findUniqueReaderTtsTextMatch(chunks: List, text: String): Int? { + val matches = chunks.mapIndexedNotNull { index, chunk -> + index.takeIf { readerTtsTextMatches(chunk.text, text) } + } + return matches.singleOrNull() + } + + private fun String.readerTtsSourceCfiBase(): String { + return substringBefore('|').substringBefore(':') + } + + private fun String.normalizedReaderTtsText(): String { + return replace(Regex("\\s+"), " ").trim() + } + + private inline fun logReaderTtsStartTrace(message: () -> String) { + logSharedReaderDiagnostic(ReaderTtsStartTraceLogTag, message) + } + + private fun ReaderLocator?.readerTtsLocatorSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "cfi=\"${cfi.orEmpty().readerTtsLogPreview(180)}\" text=\"${textQuote.orEmpty().readerTtsLogPreview(maxTextLength)}\"" + } + + private fun ReaderTtsChunk?.readerTtsChunkSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "index=$index page=$pageIndex chapter=$chapterIndex offsets=$startOffset..$endOffset " + + "sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" textChars=${text.length} " + + "text=\"${text.readerTtsLogPreview(maxTextLength)}\" spoken=\"${spokenText.readerTtsLogPreview(maxTextLength)}\"" + } + + private fun ReaderTtsChunkTarget?.readerTtsTargetSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "offset=$startOffset sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" " + + "text=\"${text.readerTtsLogPreview(maxTextLength)}\"" + } + + private fun String.readerTtsLogPreview(maxLength: Int = 120): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") + } } data class ReaderCloudTtsState( @@ -625,6 +839,31 @@ data class ReaderCloudTtsState( val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary() ) +data class ReaderCloudTtsControlsModel( + val isVisible: Boolean, + val canPauseResume: Boolean, + val canSkipPrevious: Boolean, + val canSkipNext: Boolean, + val canLocateCurrentChunk: Boolean +) + +fun readerCloudTtsControlsModel(cloudTts: ReaderCloudTtsState): ReaderCloudTtsControlsModel { + val progress = cloudTts.progress + val visible = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused + val hasCurrentChunk = progress.currentChunk != null + return ReaderCloudTtsControlsModel( + isVisible = visible, + canPauseResume = cloudTts.isPlaying || cloudTts.isPaused, + canSkipPrevious = !cloudTts.isLoading && + progress.currentChunkIndex > 0 && + progress.chunks.isNotEmpty(), + canSkipNext = !cloudTts.isLoading && + progress.currentChunkIndex >= 0 && + progress.currentChunkIndex < progress.chunks.lastIndex, + canLocateCurrentChunk = hasCurrentChunk + ) +} + data class ReaderAiResultState( val title: String? = null, val text: String = "", diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt index 424ec75..48464d1 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt @@ -5,9 +5,7 @@ private val DefaultReaderBottomToolIds: Set ReaderTool.SLIDER.id, ReaderTool.TOC.id, ReaderTool.FORMAT.id, - ReaderTool.SEARCH.id, - ReaderTool.AI_FEATURES.id, - ReaderTool.TTS_CONTROLS.id + ReaderTool.SEARCH.id ) enum class ReaderTool( @@ -22,8 +20,8 @@ enum class ReaderTool( TOC("toc", "Sidebar", "Bottom Bar"), FORMAT("format", "Text Formatting", "Bottom Bar"), SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true), - AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true), - TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true), + AI_FEATURES("ai_features", "AI Features", "Bottom Bar"), + TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"), READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"), BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true), TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"), @@ -31,7 +29,6 @@ enum class ReaderTool( PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"), KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"), VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"), - AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true), TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"), TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu"); diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt index d2a30b1..876e694 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt @@ -90,22 +90,11 @@ data class ReaderTtsReplacementApplyResult( object ReaderTtsReplacementEngine { fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation { - if (rule.from.isBlank()) { - return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.") - } - if (!rule.isRegex) { - return ReaderTtsReplacementValidation(isValid = true) - } - return runCatching { rule.toRegex() } - .fold( - onSuccess = { ReaderTtsReplacementValidation(isValid = true) }, - onFailure = { - ReaderTtsReplacementValidation( - isValid = false, - message = it.message ?: "This regex is not valid.", - ) - }, - ) + val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule()) + return ReaderTtsReplacementValidation( + isValid = validation.isValid, + message = validation.message, + ) } fun apply( @@ -117,52 +106,31 @@ object ReaderTtsReplacementEngine { return ReaderTtsReplacementApplyResult(text = text) } - var current = text - val applied = mutableListOf() - val errors = mutableListOf() - - preferences.activeRulesForBook(bookId).forEach { rule -> - if (!rule.enabled || rule.from.isBlank()) return@forEach - val regex = runCatching { rule.toRegex() } - .onFailure { - errors += ReaderTtsReplacementError( - ruleId = rule.id, - message = it.message ?: "Invalid regex.", - ) - } - .getOrNull() ?: return@forEach - val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to) - val next = runCatching { regex.replace(current, replacement) } - .onFailure { - errors += ReaderTtsReplacementError( - ruleId = rule.id, - message = it.message ?: "Invalid replacement.", - ) - } - .getOrNull() ?: return@forEach - if (next != current) { - applied += rule.id - current = next - } - } + val result = ReaderWordReplacementEngine.apply( + text = text, + rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() }, + ) return ReaderTtsReplacementApplyResult( - text = current, - appliedRuleIds = applied, - errors = errors, + text = result.text, + appliedRuleIds = result.appliedRuleIds, + errors = result.errors.map { + ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message) + }, ) } +} - private fun ReaderTtsReplacementRule.toRegex(): Regex { - val source = if (isRegex) from else Regex.escape(from) - val boundedSource = if (wholeWord) { - """(? = emptyList(), + val errors: List = emptyList(), +) + +object ReaderWordReplacementEngine { + fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation { + if (rule.from.isBlank()) { + return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.") + } + if (!rule.isRegex) { + return ReaderWordReplacementValidation(isValid = true) + } + return runCatching { rule.toRegex() } + .fold( + onSuccess = { ReaderWordReplacementValidation(isValid = true) }, + onFailure = { + ReaderWordReplacementValidation( + isValid = false, + message = it.message ?: "This regex is not valid.", + ) + }, + ) + } + + fun apply( + text: String, + rules: List, + ): ReaderWordReplacementApplyResult { + if (text.isEmpty() || rules.isEmpty()) { + return ReaderWordReplacementApplyResult(text = text) + } + + var current = text + val applied = mutableListOf() + val errors = mutableListOf() + + rules.forEach { rule -> + if (!rule.enabled || rule.from.isBlank()) return@forEach + val regex = runCatching { rule.toRegex() } + .onFailure { + errors += ReaderWordReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid regex.", + ) + } + .getOrNull() ?: return@forEach + val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to) + val next = runCatching { regex.replace(current, replacement) } + .onFailure { + errors += ReaderWordReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid replacement.", + ) + } + .getOrNull() ?: return@forEach + if (next != current) { + applied += rule.id + current = next + } + } + + return ReaderWordReplacementApplyResult( + text = current, + appliedRuleIds = applied, + errors = errors, + ) + } + + private fun ReaderWordReplacementRule.toRegex(): Regex { + val source = if (isRegex) from else Regex.escape(from) + val boundedSource = if (wholeWord) { + """(?> = emptyMap(), +) { + fun rulesForFile(fileId: String?): List { + return fileRules[fileId.orEmpty()].orEmpty() + } + + fun activeRulesForFile(fileId: String?): List { + return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() } + } + + fun withFileRules( + fileId: String?, + rules: List, + ): ReaderBookReplacementPreferences { + val key = fileId.orEmpty() + val nextRules = if (rules.isEmpty()) { + fileRules - key + } else { + fileRules + (key to rules) + } + return copy(fileRules = nextRules) + } + + fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences { + val key = fileId.orEmpty() + val rules = rulesForFile(key) + return if (rules.isEmpty()) { + ReaderBookReplacementPreferences() + } else { + ReaderBookReplacementPreferences(fileRules = mapOf(key to rules)) + } + } + + fun signatureForFile(fileId: String?): String { + return activeRulesForFile(fileId).joinToString(separator = "|") { rule -> + listOf( + rule.id, + rule.from, + rule.to, + rule.enabled.toString(), + rule.isRegex.toString(), + rule.matchCase.toString(), + rule.wholeWord.toString(), + ).joinToString(separator = "\u001F") + } + } +} + +object ReaderBookReplacementEngine { + fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation { + return ReaderWordReplacementEngine.validate(rule) + } + + fun apply( + text: String, + preferences: ReaderBookReplacementPreferences, + fileId: String?, + ): ReaderWordReplacementApplyResult { + return ReaderWordReplacementEngine.apply( + text = text, + rules = preferences.activeRulesForFile(fileId), + ) + } +} + +object ReaderBookReplacementPreferencesJson { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = false + encodeDefaults = true + } + + fun encode(preferences: ReaderBookReplacementPreferences): String { + return json.encodeToString(preferences) + } + + fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences { + if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences() + return runCatching { + json.decodeFromString(raw) + }.getOrNull() ?: ReaderBookReplacementPreferences() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt index a2b46ba..b500c01 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SettingsHubModels.kt @@ -646,6 +646,7 @@ data class SharedSettingsHubInput( val isSignedIn: Boolean = false, val isProUser: Boolean = false, val accountAvailable: Boolean = true, + val includeAccountAuthActions: Boolean = true, val syncAvailable: Boolean = true, val folderSyncAvailable: Boolean = true, val aiSettingsAvailable: Boolean = true, @@ -750,7 +751,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode SharedSettingsSectionModel( section = SharedSettingsSection.SYNC_ACCOUNTS, items = buildList { - if (input.accountAvailable && input.featurePolicy.aiAndCloud) { + if (input.includeAccountAuthActions && input.accountAvailable && input.featurePolicy.aiAndCloud) { if (input.isSignedIn) { add( SharedSettingsItemModel( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt index a102c63..c51cb64 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFeaturePolicy.kt @@ -11,6 +11,11 @@ data class SharedFeaturePolicy( ) { companion object { val Standard = SharedFeaturePolicy() + val OssOnline = SharedFeaturePolicy( + networkAccess = true, + aiAndCloud = true, + byokAi = true + ) val OssOffline = SharedFeaturePolicy( networkAccess = false, opdsCatalogs = false, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLegalLinks.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLegalLinks.kt new file mode 100644 index 0000000..b4bebc8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLegalLinks.kt @@ -0,0 +1,34 @@ +package com.aryan.reader.shared + +const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy" + +enum class SharedLegalProfile { + STANDARD, + OSS +} + +data class SharedLegalLinks( + val privacyPolicyUrl: String, + val termsUrl: String, + val licensesUrl: String +) + +fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks { + val privacyPath: String + val termsPath: String + when (profile) { + SharedLegalProfile.STANDARD -> { + privacyPath = "privacy-policy.html" + termsPath = "terms-and-conditions.html" + } + SharedLegalProfile.OSS -> { + privacyPath = "oss-privacy-policy.html" + termsPath = "oss-terms-of-service.html" + } + } + return SharedLegalLinks( + privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath", + termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath", + licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html" + ) +} 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 5589377..b41a272 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt @@ -45,8 +45,10 @@ data class SharedLibrarySnapshot( val appSeedColor: Color? = null, val appFontPreference: AppFontPreference = AppFontPreference.System, val customAppThemes: List = emptyList(), + val customReaderThemes: List = emptyList(), val readerDefaultSettings: ReaderSettings = ReaderSettings(), val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"), + val desktopReaderDefaultsVersion: Int = 0, val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), @@ -54,7 +56,7 @@ data class SharedLibrarySnapshot( ) object SharedLibrarySnapshotJson { - private const val SCHEMA_VERSION = 20 + private const val SCHEMA_VERSION = 22 private val json = Json { prettyPrint = true @@ -106,11 +108,15 @@ object SharedLibrarySnapshotJson { ?.asAppFontPreferenceOrNull() ?: AppFontPreference.System, customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() }, + customReaderThemes = root.array("customReaderThemes") + .mapNotNull { it.asReaderThemeOrNull() } + .sanitizeCustomReaderThemes(), readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion), pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"] ?.takeUnless { it is JsonNull } ?.asReaderSettingsOrNull() ?: ReaderSettings(themeId = "no_theme"), + desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0), readerToolbarPreferences = root["readerToolbarPreferences"] ?.takeUnless { it is JsonNull } ?.asReaderToolbarPreferencesOrNull() @@ -154,8 +160,12 @@ object SharedLibrarySnapshotJson { "appSeedColor" to snapshot.appSeedColor.asJson(), "appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(), "customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }), + "customReaderThemes" to JsonArray( + snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() } + ), "readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(), "pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(), + "desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion), "readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(), "readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(), "pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(), @@ -279,7 +289,8 @@ private fun JsonElement.asBookItemOrNull(): BookItem? { readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(), readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() }, readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }, - pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull() + pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(), + readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp") ) } @@ -336,7 +347,8 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? { .mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() } .filter { it in SharedFileCapabilities.knownFileTypes } .toSet() - .ifEmpty { SharedFileCapabilities.knownFileTypes } + .ifEmpty { SharedFileCapabilities.knownFileTypes }, + localSyncEnabled = obj.boolean("localSyncEnabled", true) ) } @@ -349,6 +361,19 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? { ) } +private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return ReaderTheme( + id = obj.string("id") ?: return null, + name = obj.string("name") ?: return null, + backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null, + textColor = obj.int("textColor")?.let { Color(it) } ?: return null, + isDark = obj.boolean("isDark", false), + textureId = obj.string("textureId")?.takeIf { it.isNotBlank() }, + isCustom = true + ) +} + private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? { val obj = runCatching { jsonObject }.getOrNull() ?: return null val kind = obj.string("kind") @@ -391,7 +416,8 @@ private fun BookItem.toJsonObject(): JsonObject { "readerSettings" to readerSettings.asJson(), "readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }), "readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }), - "pdfReaderViewport" to pdfReaderViewport.asJson() + "pdfReaderViewport" to pdfReaderViewport.asJson(), + "readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp) ) ) } @@ -451,7 +477,8 @@ private fun SyncedFolder.toJsonObject(): JsonObject { .filter { it in SharedFileCapabilities.knownFileTypes } .map { it.name } .sorted() - .asJsonArray() + .asJsonArray(), + "localSyncEnabled" to JsonPrimitive(localSyncEnabled) ) ) } @@ -466,6 +493,18 @@ private fun CustomAppTheme.toJsonObject(): JsonObject { ) } +private fun ReaderTheme.toJsonObject(): JsonObject { + val values = mutableMapOf( + "id" to JsonPrimitive(id), + "name" to JsonPrimitive(name), + "bgColor" to JsonPrimitive(backgroundColor.toArgb()), + "textColor" to JsonPrimitive(textColor.toArgb()), + "isDark" to JsonPrimitive(isDark) + ) + textureId?.let { values["textureId"] = JsonPrimitive(it) } + return JsonObject(values) +} + private fun AppFontPreference.toJsonObject(): JsonObject { val sanitized = sanitized() return JsonObject( @@ -529,6 +568,7 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? { pageSpreadMode = obj.string("pageSpreadMode") ?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() } ?: defaults.pageSpreadMode, + rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination), pdfVerticalPageGapVisible = obj.boolean( "pdfVerticalPageGapVisible", defaults.pdfVerticalPageGapVisible @@ -650,6 +690,8 @@ private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? { pageIndex = obj.int("pageIndex"), startOffset = obj.int("startOffset"), endOffset = obj.int("endOffset"), + blockIndex = obj.int("blockIndex"), + charOffset = obj.int("charOffset"), textQuote = obj.string("textQuote"), cfi = obj.string("cfi") ) @@ -681,6 +723,7 @@ private fun ReaderSettings?.asJson(): JsonElement { "pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name), "pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name), "pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name), + "rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination), "pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible), "pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible), "pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread), @@ -767,6 +810,8 @@ private fun ReaderLocator.toJsonObject(): JsonObject { pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } startOffset?.let { put("startOffset", JsonPrimitive(it)) } endOffset?.let { put("endOffset", JsonPrimitive(it)) } + blockIndex?.let { put("blockIndex", JsonPrimitive(it)) } + charOffset?.let { put("charOffset", JsonPrimitive(it)) } textQuote?.let { put("textQuote", JsonPrimitive(it)) } cfi?.let { put("cfi", JsonPrimitive(it)) } } 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 0f79da6..bc7788c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt @@ -89,6 +89,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { appSeedColor = if (shouldClearSeed) null else appSeedColor ) } + is AppAction.CustomReaderThemesChanged -> copy( + customReaderThemes = action.themes.sanitizeCustomReaderThemes() + ) is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled) is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled) is AppAction.TabsEnabledChanged -> copy( @@ -101,9 +104,11 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { if (bookId.isBlank()) { this } else { + val currentTabIds = openTabIds.distinct() + val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId copy( isTabsEnabled = true, - openTabIds = (openTabIds - bookId) + bookId, + openTabIds = nextTabIds, activeTabBookId = bookId ) } 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 9eabf11..2e7cdd9 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 @@ -47,10 +47,18 @@ data class OpdsAcquisition( mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI" mimeType.contains("fictionbook", ignoreCase = true) || mimeType.contains("fb2", ignoreCase = true) -> "FB2" - mimeType.contains("cbz", ignoreCase = true) || - mimeType.contains("comicbook", ignoreCase = true) -> "CBZ" + mimeType.contains("cbt", ignoreCase = true) || + mimeType.contains("comicbook+tar", ignoreCase = true) || + mimeType.contains("x-tar", ignoreCase = true) || + mimeType.equals("application/tar", ignoreCase = true) -> "CBT" mimeType.contains("cbr", ignoreCase = true) || + mimeType.contains("comicbook-rar", ignoreCase = true) || mimeType.contains("rar", ignoreCase = true) -> "CBR" + mimeType.contains("cb7", ignoreCase = true) || + mimeType.contains("7z", ignoreCase = true) -> "CB7" + mimeType.contains("cbz", ignoreCase = true) || + mimeType.contains("comicbook+zip", ignoreCase = true) || + mimeType.contains("comicbook", ignoreCase = true) -> "CBZ" mimeType.contains("txt", ignoreCase = true) || mimeType.contains("text/plain", ignoreCase = true) -> "TXT" else -> mimeType.substringAfterLast("/").uppercase() @@ -63,7 +71,7 @@ data class OpdsAcquisition( "PPTX" -> 4 "MOBI" -> 3 "FB2", "MD", "HTML" -> 2 - "CBZ", "CBR", "CB7" -> 1 + "CBZ", "CBR", "CB7", "CBT" -> 1 "TXT" -> 0 else -> -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 97bdc46..b135fa2 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 @@ -1,5 +1,6 @@ package com.aryan.reader.shared.opds +import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.SharedFileCapabilities object SharedOpdsSearch { @@ -18,7 +19,14 @@ object SharedOpdsSearch { fun expandSearchTemplate(template: String, query: String): String { val encoded = query.percentEncode() - val expandedSearchTerms = template.replace("{searchTerms}", encoded) + val expandedSearchTerms = template + .replace("{searchTerms}", encoded) + .replace("{count}", DefaultSearchCount) + .replace("{startPage}", DefaultSearchStartPage) + .replace("{startIndex}", DefaultSearchStartIndex) + .replace("{language}", DefaultSearchLanguage) + .replace("{inputEncoding}", DefaultSearchEncoding) + .replace("{outputEncoding}", DefaultSearchEncoding) if (expandedSearchTerms != template) return expandedSearchTerms val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template) @@ -56,6 +64,12 @@ object SharedOpdsSearch { contains("{query}") || contains("{keyword}") } + + private const val DefaultSearchCount = "12" + private const val DefaultSearchStartPage = "1" + private const val DefaultSearchStartIndex = "1" + private const val DefaultSearchLanguage = "*" + private const val DefaultSearchEncoding = "UTF-8" } object SharedOpdsDownloadNamer { @@ -82,6 +96,7 @@ object SharedOpdsDownloadNamer { "CBZ" -> ".cbz" "CBR" -> ".cbr" "CB7" -> ".cb7" + "CBT" -> ".cbt" "MD" -> ".md" "HTML" -> ".html" "TXT" -> ".txt" @@ -125,6 +140,101 @@ object SharedOpdsDownloadNamer { } } +object SharedOpdsLocalBookMatcher { + fun findBook(entry: OpdsEntry, books: List): BookItem? { + return find( + entry = entry, + books = books, + title = { it.title }, + displayName = { it.displayName }, + path = { it.path } + ) + } + + fun find( + entry: OpdsEntry, + books: List, + title: (T) -> String?, + displayName: (T) -> String?, + path: (T) -> String? + ): T? { + val entryKeys = entry.matchKeys() + return books.firstOrNull { book -> + book.matchKeys(title, displayName, path).any { it in entryKeys } + } + } + + private fun OpdsEntry.matchKeys(): Set { + return buildSet { + addNormalized(title) + val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title) + addNormalized(safeTitle) + addNormalized(safeTitle.take(50)) + acquisitions.forEach { acquisition -> + addFileNameKeys(acquisition.url) + } + } + } + + private fun T.matchKeys( + title: (T) -> String?, + displayName: (T) -> String?, + path: (T) -> String? + ): Set { + return buildSet { + addNormalized(title(this@matchKeys)) + addFileNameKeys(displayName(this@matchKeys)) + addFileNameKeys(path(this@matchKeys)) + } + } + + private fun MutableSet.addFileNameKeys(value: String?) { + val decodedName = value + ?.substringBefore('?') + ?.substringBefore('#') + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.percentDecode() + ?.takeIf { it.isNotBlank() } + ?: return + addNormalized(decodedName) + addNormalized(decodedName.withoutKnownExtension()) + addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix()) + } + + private fun MutableSet.addNormalized(value: String?) { + val normalized = value?.normalizedMatchKey() ?: return + if (normalized.isNotBlank()) add(normalized) + } + + private fun String.withoutKnownExtension(): String { + val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this) + if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) { + return dropLast(knownSuffix.length) + } + val extension = substringAfterLast('.', missingDelimiterValue = "") + return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) { + substringBeforeLast('.') + } else { + this + } + } + + private fun String.normalizedMatchKey(): String { + return percentDecode() + .withoutOpdsDownloadPrefix() + .replace(Regex("""[^\p{L}\p{N}]+"""), " ") + .trim() + .lowercase() + .replace(Regex("""\s+"""), " ") + .removePrefix("opds dl ") + } + + private fun String.withoutOpdsDownloadPrefix(): String { + return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "") + } +} + object SharedOpdsStreamUri { private const val SCHEME_PREFIX = "opds-pse://stream" 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 1797f58..d51bfae 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 @@ -48,6 +48,38 @@ data class SharedPdfAnnotationComment( val modifiedAt: Long = 0L ) +const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader" + +fun List.visiblePdfAnnotationComments(): List { + val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet() + return filter { it.contents.isNotBlank() } + .map { comment -> + if (comment.parentId != null && comment.parentId !in visibleCommentIds) { + comment.copy(parentId = null) + } else { + comment + } + } +} + +fun List.pdfCommentChildren(parentId: String?): List { + return filter { it.parentId == parentId } + .sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id })) +} + +fun List.withoutPdfCommentThread(commentId: String): List { + val childrenByParentId = groupBy { it.parentId } + val idsToRemove = mutableSetOf() + + fun collect(id: String) { + if (!idsToRemove.add(id)) return + childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) } + } + + collect(commentId) + return filterNot { it.id in idsToRemove } +} + @Serializable data class SharedPdfAnnotation( val id: String, @@ -162,13 +194,7 @@ object SharedPdfAnnotationDefaults { 0xFFFFFFFF.toInt() ) - val highlighterPalette: List = listOf( - 0x8CFF9800.toInt(), - 0x8CFFEB3B.toInt(), - 0x8C81C784.toInt(), - 0x8C64B5F6.toInt(), - 0x8CE1BEE7.toInt() - ) + val highlighterPalette: List = SharedPdfAndroidHighlightColors.palette.take(4) fun configFor(tool: PdfInkTool): PdfToolConfig { return when (tool) { @@ -176,8 +202,8 @@ object SharedPdfAnnotationDefaults { PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f) PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f) PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f) - PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f) - PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f) + PdfInkTool.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f) + PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f) PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f) PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f) } @@ -209,9 +235,11 @@ data class SharedPdfHighlighterPalette( companion object { const val DefaultAlpha: Int = 0x8C - const val MaxColors: Int = 5 + const val MaxColors: Int = 4 val defaultColors: List - get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() } + get() = SharedPdfAnnotationDefaults.highlighterPalette + .take(MaxColors) + .map { it.withPdfHighlighterAlpha() } } } @@ -219,18 +247,21 @@ object SharedPdfAndroidHighlightColors { const val StoredAlpha: Int = 0x8C const val RenderAlpha: Float = 0.4f + val orderedNames: List = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE") + val colorsByName: Map = mapOf( - "YELLOW" to 0xFFFBC02D.toInt(), - "GREEN" to 0xFF388E3C.toInt(), - "BLUE" to 0xFF1976D2.toInt(), - "RED" to 0xFFD32F2F.toInt() + "ORANGE" to 0xFFFF9800.toInt(), + "YELLOW" to 0xFFFFEB3B.toInt(), + "GREEN" to 0xFF81C784.toInt(), + "BLUE" to 0xFF64B5F6.toInt(), + "PURPLE" to 0xFFE1BEE7.toInt() ) val palette: List - get() = colorsByName.keys.map(::argbForName) + get() = orderedNames.map(::argbForName) fun argbForName(name: String): Int { - val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW") + val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE") return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF) } @@ -242,7 +273,7 @@ object SharedPdfAndroidHighlightColors { 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" + }?.key ?: "ORANGE" } fun nearestArgb(argb: Int): Int { 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 9332b1f..fabcad0 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 @@ -186,12 +186,20 @@ data class SharedPdfReaderState( val isTextSelectionMode: Boolean = false, val bookmarks: List = emptyList(), val selectedAnnotationId: String? = null, - val annotations: List = emptyList() + val annotations: List = emptyList(), + val toolConfigs: Map = emptyMap(), + val penPalette: List = SharedPdfAnnotationDefaults.penPalette, + val lastActivePenTool: PdfInkTool = PdfInkTool.PEN, + val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER, + val annotationUndoStack: List = emptyList(), + val annotationRedoStack: List = emptyList() ) { val safePageCount: Int get() = pageCount.coerceAtLeast(0) val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0) val canGoPrevious: Boolean get() = pageIndex > 0 val canGoNext: Boolean get() = pageIndex < lastPageIndex + val canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty() + val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty() val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState { @@ -202,6 +210,10 @@ data class SharedPdfReaderState( activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1), zoom = zoomSpec.clamp(zoom), bookmarks = bookmarks.normalizedBookmarks(lastPageIndex), + penPalette = penPalette.sanitizedSharedPdfPenPalette(), + lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN, + lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool } + ?: PdfInkTool.HIGHLIGHTER, selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId -> annotations.any { it.id == selectedId } } @@ -225,6 +237,11 @@ data class SharedPdfReaderState( } } +sealed interface SharedPdfAnnotationHistoryAction { + data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction + data class Remove(val itemsByPage: Map>) : SharedPdfAnnotationHistoryAction +} + sealed interface SharedPdfReaderAction { data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction data object PreviousPage : SharedPdfReaderAction @@ -248,6 +265,7 @@ sealed interface SharedPdfReaderAction { data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction + data class PenPaletteChanged(val colors: List) : SharedPdfReaderAction data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction data class BookmarksLoaded(val bookmarks: List) : SharedPdfReaderAction data class BookmarkToggled( @@ -262,6 +280,8 @@ sealed interface SharedPdfReaderAction { data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction data class AnnotationsChanged(val annotations: List) : SharedPdfReaderAction data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction + data object UndoAnnotationEdit : SharedPdfReaderAction + data object RedoAnnotationEdit : SharedPdfReaderAction data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction } @@ -327,16 +347,23 @@ fun SharedPdfReaderState.reduce( } } is SharedPdfReaderAction.ToolSelected -> { - val config = SharedPdfAnnotationDefaults.configFor(action.tool) + val config = toolConfigFor(action.tool) copy( selectedTool = action.tool, selectedColorArgb = config.colorArgb, strokeWidth = config.strokeWidth, - isTextSelectionMode = false + isTextSelectionMode = false, + lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool, + lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) { + action.tool + } else { + lastActiveHighlighterTool + } ) } - is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb) - is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f)) + is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb) + is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f)) + is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette()) is SharedPdfReaderAction.TextSelectionModeChanged -> { if (action.enabled) { val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE) @@ -365,10 +392,19 @@ fun SharedPdfReaderState.reduce( } copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex)) } - is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList()) + is SharedPdfReaderAction.AnnotationsLoaded -> copy( + annotations = action.annotations.toList(), + annotationUndoStack = emptyList(), + annotationRedoStack = emptyList() + ) is SharedPdfReaderAction.AnnotationAdded -> copy( annotations = annotations + action.annotation, - selectedAnnotationId = action.annotation.id + selectedAnnotationId = action.annotation.id, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add( + pageIndex = action.annotation.pageIndex, + annotation = action.annotation + ), + annotationRedoStack = emptyList() ) is SharedPdfReaderAction.AnnotationSelected -> copy( selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } } @@ -378,36 +414,158 @@ fun SharedPdfReaderState.reduce( if (index < 0) { this } else { - copy(annotations = annotations.toMutableList().also { it[index] = action.annotation }) + copy( + annotations = annotations.toMutableList().also { it[index] = action.annotation }, + annotationRedoStack = emptyList() + ) } } - is SharedPdfReaderAction.AnnotationDeleted -> copy( - annotations = annotations.filterNot { it.id == action.annotationId }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId } + is SharedPdfReaderAction.AnnotationDeleted -> { + val removed = annotations.firstOrNull { it.id == action.annotationId } + if (removed == null) { + this + } else { + copy( + annotations = annotations.filterNot { it.id == action.annotationId }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(removed.pageIndex to listOf(removed)) + ), + annotationRedoStack = emptyList() + ) + } + } + is SharedPdfReaderAction.AnnotationsChanged -> copy( + annotations = action.annotations.toList(), + annotationUndoStack = emptyList(), + annotationRedoStack = emptyList() ) - is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList()) is SharedPdfReaderAction.UndoLastAnnotationOnPage -> { val index = annotations.indexOfLast { it.pageIndex == action.pageIndex } if (index < 0) { this } else { + val removed = annotations[index] val removedId = annotations[index].id copy( annotations = annotations.toMutableList().also { it.removeAt(index) }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId } + selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(removed.pageIndex to listOf(removed)) + ), + annotationRedoStack = emptyList() ) } } + SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit() + SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit() is SharedPdfReaderAction.ClearPageAnnotations -> { - val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet() - copy( - annotations = annotations.filterNot { it.pageIndex == action.pageIndex }, - selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds } - ) + val removed = annotations.filter { it.pageIndex == action.pageIndex } + if (removed.isEmpty()) { + this + } else { + val removedIds = removed.mapTo(mutableSetOf()) { it.id } + copy( + annotations = annotations.filterNot { it.pageIndex == action.pageIndex }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }, + annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove( + itemsByPage = mapOf(action.pageIndex to removed) + ), + annotationRedoStack = emptyList() + ) + } } }.coerced(zoomSpec) } +private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig { + return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool) +} + +private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState { + if (!selectedTool.isSharedPdfConfigurableTool) { + return copy(selectedColorArgb = colorArgb) + } + val currentConfig = toolConfigFor(selectedTool) + return copy( + selectedColorArgb = colorArgb, + toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb)) + ) +} + +private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState { + if (!selectedTool.isSharedPdfConfigurableTool) { + return copy(strokeWidth = strokeWidth) + } + val currentConfig = toolConfigFor(selectedTool) + return copy( + strokeWidth = strokeWidth, + toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth)) + ) +} + +private fun List.sanitizedSharedPdfPenPalette(): List { + val defaults = SharedPdfAnnotationDefaults.penPalette + val normalized = filter { it != 0 }.take(defaults.size) + val filled = if (normalized.isEmpty()) { + defaults + } else { + normalized + defaults.drop(normalized.size) + } + return filled.take(defaults.size) +} + +private val PdfInkTool.isSharedPdfPenTool: Boolean + get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL + +private val PdfInkTool.isSharedPdfHighlighterTool: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +private val PdfInkTool.isSharedPdfConfigurableTool: Boolean + get() = this != PdfInkTool.NONE + +private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState { + val action = annotationUndoStack.lastOrNull() ?: return this + val nextUndoStack = annotationUndoStack.dropLast(1) + return when (action) { + is SharedPdfAnnotationHistoryAction.Add -> copy( + annotations = annotations.filterNot { it.id == action.annotation.id }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id }, + annotationUndoStack = nextUndoStack, + annotationRedoStack = annotationRedoStack + action + ) + + is SharedPdfAnnotationHistoryAction.Remove -> copy( + annotations = annotations + action.itemsByPage.values.flatten(), + annotationUndoStack = nextUndoStack, + annotationRedoStack = annotationRedoStack + action + ) + } +} + +private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState { + val action = annotationRedoStack.lastOrNull() ?: return this + val nextRedoStack = annotationRedoStack.dropLast(1) + return when (action) { + is SharedPdfAnnotationHistoryAction.Add -> copy( + annotations = annotations + action.annotation, + selectedAnnotationId = action.annotation.id, + annotationUndoStack = annotationUndoStack + action, + annotationRedoStack = nextRedoStack + ) + + is SharedPdfAnnotationHistoryAction.Remove -> { + val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id } + copy( + annotations = annotations.filterNot { it.id in removedIds }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }, + annotationUndoStack = annotationUndoStack + action, + annotationRedoStack = nextRedoStack + ) + } + } +} + object SharedPdfSearchEngine { fun search( pageTexts: List, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt index e257e7d..b7ca73d 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt @@ -59,7 +59,9 @@ object PdfSelectionGeometry { chars: List, lineTolerance: Float = DefaultCharLineTolerance ): List { - return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() } + return mergeBoundsByLine( + bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() } + ) } fun nearestCharOnLine( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt index 3fe9572..8cfd434 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSpreadLayout.kt @@ -36,6 +36,15 @@ object PdfSpreadLayout { return listOf(start, start + 1).filter { it in 0 until pageCount } } + fun visiblePageIndicesForDisplay( + pageIndex: Int, + pageCount: Int, + settings: ReaderSettings + ): List { + val indices = visiblePageIndices(pageIndex, pageCount, settings) + return if (settings.rightToLeftPagination) indices.asReversed() else indices + } + fun spreadStartPageIndices( pageCount: Int, settings: ReaderSettings diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt index 0a4a890..79f3e2c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt @@ -2,8 +2,6 @@ package com.aryan.reader.shared.pdf import kotlin.math.sqrt -private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader" - data class SharedPdfAnnotationExportPayload( val inkAnnotations: List = emptyList(), val highlightAnnotations: List = emptyList() @@ -208,7 +206,7 @@ private fun List.toSingleVisiblePdfCommentThrea SharedPdfHighlightCommentExport( id = "${highlightId}_comments", parentId = null, - author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }, + author = root.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, contents = threadContents, createdAt = createdAt, modifiedAt = modifiedAt @@ -228,7 +226,7 @@ private fun List.formatAsPdfCommentThread(): St if (lines.isNotEmpty()) lines += "" val indent = " ".repeat(depth) - val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + val author = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } lines += "$indent$author:" comment.contents.lines().forEach { line -> lines += "$indent$line" 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 c5b85a4..4f145cf 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 @@ -19,6 +19,7 @@ import kotlinx.serialization.json.longOrNull object SharedPdfAnnotationSidecarCodec { const val KEY_PDF_ANNOTATIONS = "pdfAnnotations" + const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions" const val KEY_LEGACY_INK = "ink" const val KEY_LEGACY_TEXT_BOXES = "textBoxes" const val KEY_LEGACY_HIGHLIGHTS = "highlights" @@ -38,16 +39,17 @@ object SharedPdfAnnotationSidecarCodec { } fun annotationsFromData(data: JsonObject): List { - data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) } + val deletedIds = annotationDeletionsFromData(data).keys + data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } } data[KEY_LEGACY_INK]?.let { ink -> val decoded = decodeAnnotationsElement(ink) if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) { - return decoded + return decoded.filterNot { annotation -> annotation.id in deletedIds } } } - return legacyAndroidAnnotationsFromData(data) + return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds } } fun withCanonicalAnnotations(data: JsonObject): JsonObject { @@ -62,6 +64,80 @@ object SharedPdfAnnotationSidecarCodec { return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data)) } + fun mergeAnnotationDataJson( + localDataJson: String, + remoteDataJson: String, + preferRemoteOnConflict: Boolean + ): String { + val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap()) + val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap()) + val localCanonical = withCanonicalAnnotations(localData) + val remoteCanonical = withCanonicalAnnotations(remoteData) + val localAnnotations = annotationsFromData(localCanonical) + val remoteAnnotations = annotationsFromData(remoteCanonical) + val mergedDeletions = mergeAnnotationDeletions( + annotationDeletionsFromData(localCanonical), + annotationDeletionsFromData(remoteCanonical) + ) + val mergedById = linkedMapOf() + val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations + val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations + first.forEach { annotation -> + if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation + } + second.forEach { annotation -> + if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation + } + val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap() + base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync()) + if (mergedDeletions.isNotEmpty()) { + base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions) + } else { + base.remove(KEY_PDF_ANNOTATION_DELETIONS) + } + return json.encodeToString(JsonElement.serializer(), JsonObject(base)) + } + + fun annotationCountFromDataJson(rawDataJson: String): Int { + val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0 + return annotationsFromData(withCanonicalAnnotations(data)).size + } + + fun annotationDeletionsFromData(data: JsonObject): Map { + return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions() + } + + fun annotationDeletionsFromJson(rawJson: String): Map { + val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap() + return when (element) { + is JsonObject -> { + val data = element.sidecarDataObject() + annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() } + } + else -> element.parseAnnotationDeletions() + } + } + + fun annotationDeletionsJson(deletions: Map): String { + return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions)) + } + + fun encodeAnnotationDeletionsElement(deletions: Map): JsonElement { + return JsonArray( + deletions + .filterKeys { it.isNotBlank() } + .toSortedMap() + .map { (id, deletedAt) -> + JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "deletedAt" to JsonPrimitive(deletedAt) + ) + ) + } + ) + } + fun legacyAndroidDataFromAnnotations( annotations: List, existingData: JsonObject = JsonObject(emptyMap()) @@ -283,6 +359,47 @@ object SharedPdfAnnotationSidecarCodec { return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() } + private fun List.sortedForSync(): List { + return sortedWith( + compareBy { it.pageIndex } + .thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE } + .thenBy { it.id } + ) + } + + private fun mergeAnnotationDeletions( + local: Map, + remote: Map + ): Map { + if (local.isEmpty()) return remote + if (remote.isEmpty()) return local + return buildMap { + (local.keys + remote.keys).forEach { id -> + put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L)) + } + } + } + + private fun JsonElement?.parseAnnotationDeletions(): Map { + val element = this ?: return emptyMap() + val array = element.jsonArrayOrNull() + ?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS) + ?: return emptyMap() + return buildMap { + array.forEach { item -> + val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull + val obj = item.jsonObjectOrNull() + val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id") + if (id.isNullOrBlank()) return@forEach + val deletedAt = obj?.long("deletedAt") + ?: obj?.long("timestamp") + ?: obj?.long("ts") + ?: 0L + put(id, maxOf(this[id] ?: 0L, deletedAt)) + } + } + } + private fun stableAnnotationId(prefix: String, element: JsonElement): String { return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}" } @@ -314,6 +431,10 @@ object SharedPdfAnnotationSidecarCodec { this[KEY_LEGACY_HIGHLIGHTS] != null } + private fun JsonObject.sidecarDataObject(): JsonObject { + return this["data"]?.jsonObjectOrNull() ?: this + } + private fun JsonElement.jsonArrayOrNull(): JsonArray? { if (this is JsonNull) return null return runCatching { jsonArray }.getOrNull() @@ -324,6 +445,11 @@ object SharedPdfAnnotationSidecarCodec { return runCatching { jsonObject }.getOrNull() } + private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? { + if (this is JsonNull) return null + return runCatching { jsonPrimitive }.getOrNull() + } + private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull() private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull() 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 ca5983c..eb092cd 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 @@ -52,6 +52,17 @@ 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" +internal fun sharedPdfRichTextSelectionBounds( + selectionStart: Int, + selectionEnd: Int, + textLength: Int +): Pair? { + val safeLength = textLength.coerceAtLeast(0) + val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength) + return if (localStart < localEnd) localStart to localEnd else null +} + object SharedPdfRichTextLog { var enabled: Boolean = true 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 470d60f..8044d7b 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 @@ -2,12 +2,16 @@ package com.aryan.reader.shared.reader import com.aryan.reader.paginatedreader.SemanticBlock import com.aryan.reader.paginatedreader.SemanticFlexContainer +import com.aryan.reader.paginatedreader.SemanticImage import com.aryan.reader.paginatedreader.SemanticList +import com.aryan.reader.paginatedreader.SemanticMath +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.UserHighlight +import com.aryan.reader.shared.toStableReaderPositionCfi sealed interface ReaderLinkTarget { data class External(val url: String) : ReaderLinkTarget @@ -103,10 +107,10 @@ class ReaderEngine( highlights: List = emptyList() ): ReaderSessionState { val pages = pagesFor(book, settings) - val requestedInitialIndex = initialLocator + val locatorResolvedIndex = initialLocator ?.let { pages.findPageIndexForLocator(it) } ?.takeIf { it >= 0 } - ?: initialPageIndex + val requestedInitialIndex = locatorResolvedIndex ?: initialPageIndex val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings) val reader = PaginatedReaderState( book = book, @@ -114,7 +118,13 @@ class ReaderEngine( currentPageIndex = initialIndex, settings = settings ) - return ReaderSessionState( + logReaderPositionTrace { + "event=engine_create_session_start book=\"${book.title.positionTracePreview(120)}\" " + + "mode=${settings.readingMode} pages=${pages.size} initialPage=$initialPageIndex " + + "locatorResolved=${locatorResolvedIndex ?: "null"} requested=$requestedInitialIndex normalized=$initialIndex " + + "initialLocator=${initialLocator.positionTraceSummary()}" + } + val session = ReaderSessionState( reader = reader, bookmarks = bookmarks .mapNotNull { it.normalizedForBook(book, pages) } @@ -128,6 +138,13 @@ class ReaderEngine( ?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))) ?: reader.currentPage?.toLocator(book) ) + logReaderPositionTrace { + "event=engine_create_session_done book=\"${book.title.positionTracePreview(120)}\" " + + "mode=${settings.readingMode} currentPage=${session.reader.currentPageIndex} " + + "visiblePages=${session.reader.visiblePages.map { it.pageIndex }} " + + "navigationLocator=${session.navigationLocator.positionTraceSummary()}" + } + return session } fun next(state: ReaderSessionState): ReaderSessionState { @@ -149,10 +166,17 @@ class ReaderEngine( fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState { val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings) val page = state.reader.pages.getOrNull(target) + val locator = page?.let { + if (state.reader.settings.readingMode == ReaderReadingMode.VERTICAL) { + it.toVerticalScrollPageLocator(state.reader.book) + } else { + it.toLocator(state.reader.book) + } + } return state.copy( reader = state.reader.copy(currentPageIndex = target), activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }, - navigationLocator = page?.toLocator(state.reader.book), + navigationLocator = locator, navigationRequestId = state.navigationRequestId + 1 ) } @@ -173,15 +197,14 @@ class ReaderEngine( } fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState { - val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + val requestedPageIndex = state.reader.pages.findPageIndexForLocator(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 requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex) + val blockPosition = requestedPage.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks( chapterIndex = requestedPage.chapterIndex, chapterId = requestedChapter?.id, @@ -189,6 +212,8 @@ class ReaderEngine( pageIndex = requestedPageIndex, startOffset = requestedPage.startOffset, endOffset = requestedPage.endOffset, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = locator.textQuote ?: requestedPage.text.preview(), cfi = locator.cfi ?: requestedPage.toDesktopCfi() ) @@ -345,12 +370,26 @@ class ReaderEngine( fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState { 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( + if (target == state.reader.currentPageIndex && normalizedLocator == null) { + logReaderPositionTrace { + "event=engine_sync_visible_skip reason=unchanged_no_locator mode=${state.reader.settings.readingMode} " + + "inputPage=$pageIndex target=$target current=${state.reader.currentPageIndex}" + } + return state + } + val next = state.copy( reader = state.reader.copy(currentPageIndex = target), activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex }, navigationLocator = normalizedLocator ?: state.navigationLocator ) + logReaderPositionTrace { + "event=engine_sync_visible_done mode=${state.reader.settings.readingMode} inputPage=$pageIndex " + + "target=$target previousPage=${state.reader.currentPageIndex} nextPage=${next.reader.currentPageIndex} " + + "inputLocator=${locator.positionTraceSummary()} normalizedLocator=${normalizedLocator.positionTraceSummary()} " + + "previousNavigation=${state.navigationLocator.positionTraceSummary()} " + + "nextNavigation=${next.navigationLocator.positionTraceSummary()}" + } + return next } fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState { @@ -509,13 +548,12 @@ class ReaderEngine( chapterTitle: String? = null, preview: String? = null ): ReaderSessionState { - val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + val targetPageIndex = state.reader.pages.findPageIndexForLocator(locator) .takeIf { it >= 0 } - ?: locator.pageIndex - ?.takeIf { it in state.reader.pages.indices } ?: state.reader.currentPageIndex val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val blockPosition = page.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -523,8 +561,12 @@ class ReaderEngine( pageIndex = targetPageIndex, startOffset = page.startOffset, endOffset = page.endOffset, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = preview ?: page.text.preview(), - cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}" + cfi = locator.cfi + ?: blockPosition?.androidStyleCfi() + ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}" ) val existing = state.bookmarks.firstOrNull { it.locator.sameLocation(normalizedLocator) || @@ -679,12 +721,13 @@ class ReaderEngine( if (state.searchResults.isEmpty()) return state val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex) val result = state.searchResults[targetIndex] - val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) } + val requestedPage = state.reader.pages.findPageIndexForLocator(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) } + val blockPosition = page?.firstLocatorBlockPosition() return state.copy( reader = state.reader.copy(currentPageIndex = targetPage), activeSearchResultIndex = targetIndex, @@ -692,7 +735,9 @@ class ReaderEngine( chapterIndex = page?.chapterIndex, chapterId = chapter?.id, href = chapter?.baseHref, - pageIndex = requestedPage + pageIndex = requestedPage, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset ), navigationRequestId = state.navigationRequestId + 1 ) @@ -731,7 +776,7 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean { val start = locator.startOffset ?: return false val end = locator.endOffset ?: start return if (start == end) { - start in startOffset..endOffset + containsCollapsedOffset(start) } else { start < endOffset && end > startOffset } @@ -740,11 +785,128 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean { return targetPage != null && targetPage == pageIndex } +private fun ReaderPage.containsCollapsedOffset(offset: Int): Boolean { + return if (startOffset == endOffset) { + offset == startOffset + } else { + offset >= startOffset && offset < endOffset + } +} + private fun List.findPageIndexForLocator(locator: ReaderLocator): Int { - return indexOfFirst { page -> page.contains(locator) } - .takeIf { it >= 0 } - ?: locator.pageIndex?.takeIf { it in indices } - ?: -1 + if (locator.blockIndex != null) { + val blockIndex = findPageIndexForBlockLocator(locator) + if (blockIndex >= 0) return blockIndex + } + + if (locator.hasTextRange) { + val textRangeIndex = indexOfFirst { page -> page.containsTextRange(locator) } + if (textRangeIndex >= 0) return textRangeIndex + + if (locator.startOffset == locator.endOffset) { + val offset = locator.startOffset + val targetChapter = locator.chapterIndex + val finalBoundaryIndex = indexOfLast { page -> + (targetChapter == null || targetChapter == page.chapterIndex) && + page.startOffset < page.endOffset && + page.endOffset == offset + } + if (finalBoundaryIndex >= 0) return finalBoundaryIndex + } + } + + return locator.pageIndex?.takeIf { it in indices } ?: -1 +} + +private fun ReaderPage.containsTextRange(locator: ReaderLocator): Boolean { + val targetChapter = locator.chapterIndex + if (targetChapter != null && targetChapter != chapterIndex) return false + val start = locator.startOffset ?: return false + val end = locator.endOffset ?: start + return if (start == end) { + containsCollapsedOffset(start) + } else { + start < endOffset && end > startOffset + } +} + +private fun List.findPageIndexForBlockLocator(locator: ReaderLocator): Int { + val blockIndex = locator.blockIndex ?: return -1 + val charOffset = locator.charOffset + val targetChapter = locator.chapterIndex + var fallbackPageIndex = -1 + for ((pageIndex, page) in withIndex()) { + if (targetChapter != null && page.chapterIndex != targetChapter) continue + val blocks = page.semanticBlocks.flattenSemanticBlocks() + if (fallbackPageIndex < 0 && blocks.any { it.blockIndex == blockIndex }) { + fallbackPageIndex = pageIndex + } + if (charOffset == null) continue + for (block in blocks.filterIsInstance()) { + if (block.blockIndex != blockIndex) continue + val start = block.startCharOffsetInSource + val end = start + block.text.length + if (charOffset in start until end || (block.text.isEmpty() && charOffset == start)) { + return pageIndex + } + } + } + return fallbackPageIndex +} + +private data class ReaderBlockPosition( + val blockIndex: Int, + val charOffset: Int, + val cfi: String? = null, + val localCharOffset: Int = 0 +) { + fun androidStyleCfi(): String? { + val base = cfi + ?.takeIf { it.startsWith("/") } + ?.substringBefore(':') + ?: return null + return "$base:${localCharOffset.coerceAtLeast(0)}" + } +} + +private fun ReaderPage.firstLocatorBlockPosition(): ReaderBlockPosition? { + val blocks = semanticBlocks.flattenSemanticBlocks() + val textBlock = blocks + .filterIsInstance() + .firstOrNull { it.text.isNotBlank() } + ?: blocks.filterIsInstance().firstOrNull() + if (textBlock != null) { + return ReaderBlockPosition( + blockIndex = textBlock.blockIndex, + charOffset = textBlock.startCharOffsetInSource, + cfi = textBlock.cfi, + localCharOffset = 0 + ) + } + val firstBlock = blocks.firstOrNull() ?: return null + return ReaderBlockPosition( + blockIndex = firstBlock.blockIndex, + charOffset = 0, + cfi = firstBlock.cfi, + localCharOffset = 0 + ) +} + +private fun List.flattenSemanticBlocks(): List { + return flatMap { it.flattenSemanticBlock() } +} + +private fun SemanticBlock.flattenSemanticBlock(): List { + return when (this) { + is SemanticList -> listOf(this) + items + is SemanticTable -> listOf(this) + rows.flatMap { row -> row.flatMap { cell -> cell.content.flattenSemanticBlocks() } } + is SemanticFlexContainer -> listOf(this) + children.flattenSemanticBlocks() + is SemanticWrappingBlock -> listOf(this, floatedImage) + paragraphsToWrap + is SemanticImage, + is SemanticMath, + is SemanticSpacer, + is SemanticTextBlock -> listOf(this) + } } private fun ReaderLocator.normalizedForResolvedPage( @@ -756,6 +918,7 @@ private fun ReaderLocator.normalizedForResolvedPage( val chapter = book.chapters.getOrNull(page.chapterIndex) val start = startOffset ?: page.startOffset val end = (endOffset ?: start).coerceAtLeast(start) + val blockPosition = page.firstLocatorBlockPosition() return copy(pageIndex = page.pageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -763,18 +926,25 @@ private fun ReaderLocator.normalizedForResolvedPage( pageIndex = page.pageIndex, startOffset = start, endOffset = end, + blockIndex = blockPosition?.blockIndex, + charOffset = blockPosition?.charOffset, textQuote = textQuote ?: page.text.preview(), - cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end" + cfi = cfi + ?.toStableReaderPositionCfi() + ?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") } + ?: blockPosition?.androidStyleCfi() + ?: "desktop:${page.chapterIndex}:$start:$end" ) } private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List): ReaderBookmark? { - val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) } + val targetPageIndex = pages.findPageIndexForLocator(locator) .takeIf { it >= 0 } ?: pageIndex.takeIf { it in pages.indices } ?: return null val page = pages.getOrNull(targetPageIndex) ?: return null val chapter = book.chapters.getOrNull(page.chapterIndex) + val blockPosition = page.firstLocatorBlockPosition() val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( chapterIndex = page.chapterIndex, chapterId = chapter?.id, @@ -782,8 +952,10 @@ private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List String) { + logSharedReaderDiagnostic(ReaderPositionTraceLogTag, message) +} + +private fun ReaderLocator?.positionTraceSummary(maxTextLength: Int = 90): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "chapterId=\"${chapterId.orEmpty().positionTracePreview(80)}\" " + + "href=\"${href.orEmpty().positionTracePreview(120)}\" " + + "cfi=\"${cfi.orEmpty().positionTracePreview(180)}\" " + + "text=\"${textQuote.orEmpty().positionTracePreview(maxTextLength)}\"" +} + +private fun String.positionTracePreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} 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 83c8179..a3e8a66 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 @@ -38,9 +38,18 @@ object ReaderHtmlDocumentBuilder { readerAiFeaturesEnabled: Boolean = true, cloudTtsEnabled: Boolean = true, externalLookupEnabled: Boolean = true, - textureDataUri: String? = null + textureDataUri: String? = null, + renderedChapterRange: IntRange? = null ): String { - val body = book.chapters.mapIndexed { index, chapter -> + val renderedChapterIndices = renderedChapterRange + ?.asSequence() + ?.filter { it in book.chapters.indices } + ?.distinct() + ?.toList() + ?.takeIf { it.isNotEmpty() } + ?: book.chapters.indices.toList() + val body = renderedChapterIndices.joinToString("\n") { index -> + val chapter = book.chapters[index] val chapterText = chapter.normalizedReaderText() val chapterHtml = chapter.toHtml(searchQuery, searchOptions) .applyUserHighlights( @@ -56,7 +65,7 @@ object ReaderHtmlDocumentBuilder { """.trimIndent() - }.joinToString("\n") + } return document( title = book.title, settings = settings, @@ -133,6 +142,7 @@ object ReaderHtmlDocumentBuilder { textureDataUri: String? = null ): String { val appearance = settings.toDocumentAppearanceCss(textureDataUri) + val customFontCss = settings.readerCustomFontFaceCss() return """ (function () { var root = document.documentElement; @@ -144,6 +154,30 @@ object ReaderHtmlDocumentBuilder { 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()}); + root.style.setProperty('--reader-font-size', ${"${settings.fontSize}px".toJsStringLiteral()}); + root.style.setProperty('--reader-line-height', ${settings.lineSpacing.toString().toJsStringLiteral()}); + root.style.setProperty('--reader-page-width', ${"${settings.pageWidth}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin', ${"${settings.margin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin-x', ${"${settings.resolvedHorizontalMargin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-margin-y', ${"${settings.resolvedVerticalMargin}px".toJsStringLiteral()}); + root.style.setProperty('--reader-vertical-margin-y', ${"${settings.readerVerticalMarginY()}px".toJsStringLiteral()}); + root.style.setProperty('--reader-vertical-page-width', 'max(0px, calc(100% - (var(--reader-margin-x) * 2)))'); + root.style.setProperty('--reader-paragraph-spacing', ${settings.paragraphSpacing.toString().toJsStringLiteral()}); + root.style.setProperty('--reader-image-scale', ${settings.readerImageScaleCss().toJsStringLiteral()}); + root.style.setProperty('--reader-align', ${settings.readerTextAlignCss().toJsStringLiteral()}); + root.style.setProperty('--reader-family', ${settings.readerFontFamilyCss().toJsStringLiteral()}); + var customFontCss = ${customFontCss.toJsStringLiteral()}; + var customFontStyle = document.getElementById('reader-custom-font-style'); + if (customFontCss) { + if (!customFontStyle) { + customFontStyle = document.createElement('style'); + customFontStyle.id = 'reader-custom-font-style'; + document.head.appendChild(customFontStyle); + } + customFontStyle.textContent = customFontCss; + } else if (customFontStyle && customFontStyle.parentNode) { + customFontStyle.parentNode.removeChild(customFontStyle); + } var textureStyle = document.getElementById('reader-texture-style'); if (!textureStyle) { textureStyle = document.createElement('style'); @@ -155,6 +189,28 @@ object ReaderHtmlDocumentBuilder { """.trimIndent() } + fun pageAnchorsUpdateScript(pages: List): String { + val pageAnchorJson = pages.toPageAnchorJson() + return """ + (function () { + if (window.readerSetPageAnchors) { + window.readerSetPageAnchors($pageAnchorJson); + } + })(); + """.trimIndent() + } + + fun highlightPaletteUpdateScript(highlightPalette: ReaderHighlightPalette): String { + val highlightButtons = highlightPalette.toSelectionPaletteButtons() + return """ + (function () { + var container = document.querySelector('#reader-selection-menu .reader-selection-colors'); + if (!container) return; + container.innerHTML = ${highlightButtons.toJsStringLiteral()}; + })(); + """.trimIndent() + } + private fun pageSectionHtml( book: SharedEpubBook, page: ReaderPage, @@ -210,29 +266,10 @@ object ReaderHtmlDocumentBuilder { textureDataUri: String? ): String { val appearance = settings.toDocumentAppearanceCss(textureDataUri) - val align = when (settings.textAlign) { - SharedReaderTextAlign.START -> "left" - SharedReaderTextAlign.RIGHT -> "right" - SharedReaderTextAlign.JUSTIFY -> "justify" - SharedReaderTextAlign.CENTER -> "center" - } - val customFontUrl = settings.customFontPath?.takeIf { it.isNotBlank() }?.toCssFontUrl() - val customFontCss = customFontUrl?.let { - "@font-face { font-family: 'ReaderCustomFont'; src: url('$it'); font-display: swap; }" - }.orEmpty() - val family = if (customFontUrl != null) { - "'ReaderCustomFont', Georgia, 'Times New Roman', serif" - } else { - when (settings.fontFamily) { - "Serif" -> "Georgia, 'Times New Roman', serif" - "Sans" -> "Inter, Segoe UI, Arial, sans-serif" - "Mono" -> "'Roboto Mono', Consolas, monospace" - else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - } - } - val highlightButtons = highlightPalette.sanitized().colors.joinToString("\n") { color -> - """""" - } + val align = settings.readerTextAlignCss() + val customFontCss = settings.readerCustomFontFaceCss() + val family = settings.readerFontFamilyCss() + val highlightButtons = highlightPalette.toSelectionPaletteButtons() val defineButton = if (readerAiFeaturesEnabled) { readerSelectionActionButton("define", "Define", ReaderSelectionIconDefinePath) } else { @@ -250,9 +287,10 @@ object ReaderHtmlDocumentBuilder { } val navigationAttributes = navigationLocator?.toNavigationAttributes().orEmpty() val pageAnchorJson = pageAnchors.toPageAnchorJson() + val verticalMarginY = settings.readerVerticalMarginY() return """ - + @@ -277,8 +315,11 @@ object ReaderHtmlDocumentBuilder { --reader-margin: ${settings.margin}px; --reader-margin-x: ${settings.resolvedHorizontalMargin}px; --reader-margin-y: ${settings.resolvedVerticalMargin}px; + --reader-vertical-margin-y: ${verticalMarginY}px; + --reader-vertical-content-width: 92ch; + --reader-vertical-page-width: max(0px, calc(100% - (var(--reader-margin-x) * 2))); --reader-paragraph-spacing: ${settings.paragraphSpacing}; - --reader-image-scale: ${(settings.imageScale * 100f).roundToInt().coerceIn(50, 200)}%; + --reader-image-scale: ${settings.readerImageScaleCss()}; --reader-align: $align; --reader-family: $family; } @@ -295,6 +336,12 @@ object ReaderHtmlDocumentBuilder { scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track); scrollbar-width: thin; } + html.reader-vertical-root { + width: 100%; + min-width: 0; + overflow-y: scroll; + scrollbar-width: thin; + } html::-webkit-scrollbar, body.reader-vertical::-webkit-scrollbar { width: 12px; @@ -322,6 +369,14 @@ object ReaderHtmlDocumentBuilder { position: relative; } body.reader-vertical { + width: 100%; + max-width: 100%; + min-height: 100vh; + min-height: 100dvh; + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + padding: var(--reader-vertical-margin-y) 0; scrollbar-gutter: stable; } body.reader-paginated { @@ -335,6 +390,133 @@ object ReaderHtmlDocumentBuilder { position: relative; z-index: 1; } + body.reader-vertical .chapter { + content-visibility: auto; + contain-intrinsic-size: auto 1200px; + } + body.reader-vertical > .chapter, + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content), + body.reader-vertical > .chapter > .chapter-title, + body.reader-vertical > .chapter > .reader-content { + box-sizing: border-box !important; + min-width: 0 !important; + } + body.reader-vertical > .chapter { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + } + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content), + body.reader-vertical > .chapter > .chapter-title, + body.reader-vertical > .chapter > .reader-content { + width: var(--reader-vertical-page-width) !important; + max-width: none !important; + margin-left: auto !important; + margin-right: auto !important; + } + body.reader-vertical > :not(.chapter):not(#reader-selection-menu):not(.reader-selection-handle):not(script):not(style), + body.reader-vertical > .chapter > :not(.reader-content) { + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content :where(h1, h2, h3, h4, h5, h6, hgroup, center, [class*="title" i], [id*="title" i], [class*="heading" i], [id*="heading" i], [class*="dedication" i], [id*="dedication" i]) { + box-sizing: border-box !important; + width: auto !important; + max-width: 100% !important; + min-width: 0 !important; + margin-left: 0 !important; + margin-right: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + text-indent: 0 !important; + position: static !important; + left: auto !important; + right: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content, + body.reader-vertical .reader-content p, + body.reader-vertical .reader-content li, + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content h1, + body.reader-vertical .reader-content h2, + body.reader-vertical .reader-content h3, + body.reader-vertical .reader-content h4, + body.reader-vertical .reader-content h5, + body.reader-vertical .reader-content h6, + body.reader-vertical .reader-content blockquote { + text-align: var(--reader-align) !important; + } + body.reader-vertical .reader-content p, + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content h1, + body.reader-vertical .reader-content h2, + body.reader-vertical .reader-content h3, + body.reader-vertical .reader-content h4, + body.reader-vertical .reader-content h5, + body.reader-vertical .reader-content h6, + body.reader-vertical .reader-content blockquote, + body.reader-vertical .reader-content section, + body.reader-vertical .reader-content article, + body.reader-vertical .reader-content header, + body.reader-vertical .reader-content footer, + body.reader-vertical .reader-content aside, + body.reader-vertical .reader-content figure, + body.reader-vertical .reader-content table, + body.reader-vertical .reader-content pre { + box-sizing: border-box !important; + max-width: 100% !important; + min-width: 0 !important; + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + float: none !important; + clear: none !important; + } + body.reader-vertical .reader-content div, + body.reader-vertical .reader-content section, + body.reader-vertical .reader-content article, + body.reader-vertical .reader-content header, + body.reader-vertical .reader-content footer, + body.reader-vertical .reader-content aside, + body.reader-vertical .reader-content figure { + width: auto !important; + margin-left: 0 !important; + margin-right: 0 !important; + } + body.reader-vertical .reader-content > p, + body.reader-vertical .reader-content > div, + body.reader-vertical .reader-content > h1, + body.reader-vertical .reader-content > h2, + body.reader-vertical .reader-content > h3, + body.reader-vertical .reader-content > h4, + body.reader-vertical .reader-content > h5, + body.reader-vertical .reader-content > h6, + body.reader-vertical .reader-content > blockquote, + body.reader-vertical .reader-content > section, + body.reader-vertical .reader-content > article, + body.reader-vertical .reader-content > header, + body.reader-vertical .reader-content > footer, + body.reader-vertical .reader-content > aside, + body.reader-vertical .reader-content > figure, + body.reader-vertical .reader-content > table, + body.reader-vertical .reader-content > pre { + margin-left: 0 !important; + margin-right: 0 !important; + } body.reader-paginated .page { box-sizing: border-box; height: calc(100vh - (var(--reader-margin-y) * 2)); @@ -450,14 +632,22 @@ object ReaderHtmlDocumentBuilder { overflow-x: auto; } #reader-selection-menu .reader-selection-color { - width: 24px; - height: 24px; + 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-spectrum { + width: 28px; + height: 28px; + flex: 0 0 auto; + padding: 0; + border-radius: 999px; + background: conic-gradient(#f44336, #ff7f00, #ffeb3b, #4caf50, #2196f3, #4b0082, #8b00ff, #f44336); + } #reader-selection-menu .reader-selection-actions { display: grid; grid-template-columns: repeat(3, 70px); @@ -465,17 +655,22 @@ object ReaderHtmlDocumentBuilder { padding: 5px 6px 2px; } #reader-selection-menu .reader-selection-action { - min-height: 52px; + min-height: 56px; border-radius: 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; - padding: 6px 4px; - line-height: 1; + padding: 6px 4px 7px; + line-height: 1.15; white-space: nowrap; } + #reader-selection-menu .reader-selection-action span:last-child { + display: block; + line-height: 1.2; + padding-bottom: 1px; + } #reader-selection-menu .reader-selection-icon { display: grid; place-items: center; @@ -568,13 +763,26 @@ object ReaderHtmlDocumentBuilder { "), " ") + .replace(Regex("(?is)"), " ") + .replace(Regex("(?i)<\\s*br\\s*/?\\s*>"), "\n") + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?is)<[^>]+>"), " "), + false + ).normalizeReaderWhitespace() + } + private fun String.sanitizeReaderHtml(): String { return replace(Regex("(?is)"), "") .replace(Regex("(?is)"), "") @@ -1400,19 +1619,28 @@ object SharedJvmBookLoader { val raw = src.trim().takeIf { it.isNotBlank() } ?: return null if (raw.startsWith("data:", ignoreCase = true)) return raw if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) return raw - if (raw.startsWith("file:", ignoreCase = true)) return raw + if (raw.startsWith("file:", ignoreCase = true)) return null val clean = raw.substringBefore('#').substringBefore('?').takeIf { it.isNotBlank() } ?: return null val decoded = runCatching { URLDecoder.decode(clean, Charsets.UTF_8.name()) }.getOrDefault(clean) - val direct = File(decoded) - if (direct.isAbsolute && direct.isFile) return direct.toURI().toString() + val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let { path -> + val file = File(path) + if (file.isAbsolute) file else null + } + val extractionRoot = extractionBasePath + .trim() + .takeIf { it.isNotBlank() } + ?.let { runCatching { File(it).canonicalFile }.getOrNull() } + ?: chapterFile + ?.parentFile + ?.let { runCatching { it.canonicalFile }.getOrNull() } + ?: return null - val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let(::File) - val chapterRelative = chapterFile?.parentFile?.let { File(it, decoded) } - if (chapterRelative?.isFile == true) return chapterRelative.toURI().toString() + val resolvedChapterFile = chapterFile ?: File(extractionRoot, chapterAbsPath) + val chapterRelative = resolvedChapterFile.parentFile?.let { File(it, decoded) } + fileInsideRootOrNull(extractionRoot, chapterRelative)?.let { return it.absolutePath } - val extractionRelative = extractionBasePath.trim().takeIf { it.isNotBlank() }?.let { File(it, decoded) } - if (extractionRelative?.isFile == true) return extractionRelative.toURI().toString() + fileInsideRootOrNull(extractionRoot, File(extractionRoot, decoded))?.let { return it.absolutePath } return null } @@ -1450,6 +1678,15 @@ object SharedJvmBookLoader { else -> File(this) } } + + private fun fileInsideRootOrNull(root: File, candidate: File?): File? { + val file = candidate ?: return null + val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return null + val rootPath = root.path + val targetPath = canonical.path + val insideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) + return canonical.takeIf { insideRoot && it.isFile } + } } private fun List.semanticFallbackText(): String { diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt index 1c7724c..5cc580a 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import kotlin.math.roundToInt class SharedMeasuredEpubPaginator( @@ -50,29 +51,33 @@ class SharedMeasuredEpubPaginator( suspend fun paginate( book: SharedEpubBook, settings: ReaderSettings, - viewport: ReaderViewportSpec + viewport: ReaderViewportSpec, + readCache: Boolean = true ): List { currentCoroutineContext().ensureActive() - pageCache?.load( - book = book, - settings = settings, - viewport = viewport, - density = density.density, - fontScale = density.fontScale - )?.let { cached -> - logEpubPagination { - "cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + - "viewport=${viewport.widthPx}x${viewport.heightPx} spread=${settings.pageSpreadMode}" + if (readCache) { + pageCache?.load( + book = book, + settings = settings, + viewport = viewport, + density = density.density, + fontScale = density.fontScale + )?.let { cached -> + logEpubPagination { + "cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + + "viewport=${viewport.widthPx}x${viewport.heightPx} spread=${settings.pageSpreadMode}" + } + logEpubPageFit { + "page_fit layer=cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + + "note=clear_book_cache_to_capture_layer_measured" + } + return cached } - logEpubPageFit { - "page_fit layer=cache_hit book=\"${book.title.logPreview()}\" pages=${cached.size} " + - "note=clear_book_cache_to_capture_layer_measured" - } - return cached } currentCoroutineContext().ensureActive() - val geometry = measuredPageGeometryFor(settings, viewport, density.density) + val geometryTerms = measuredPageGeometryTerms(settings, viewport, density.density) + val geometry = geometryTerms.geometry logEpubPagination { "paginate_start book=\"${book.title.logPreview()}\" chapters=${book.chapters.size} " + "viewport=${viewport.widthPx}x${viewport.heightPx} page=${geometry.pageWidthPx}x${geometry.pageHeightPx} " + @@ -89,6 +94,7 @@ class SharedMeasuredEpubPaginator( val pages = mutableListOf() book.chapters.forEachIndexed { chapterIndex, chapter -> currentCoroutineContext().ensureActive() + yield() pages += paginateChapter( chapter = chapter, chapterIndex = chapterIndex, @@ -124,6 +130,43 @@ class SharedMeasuredEpubPaginator( return measuredPages } + suspend fun paginateChapterWindow( + book: SharedEpubBook, + settings: ReaderSettings, + viewport: ReaderViewportSpec, + chapterIndex: Int, + firstPageIndex: Int + ): List { + currentCoroutineContext().ensureActive() + val chapter = book.chapters.getOrNull(chapterIndex) ?: return emptyList() + val geometryTerms = measuredPageGeometryTerms(settings, viewport, density.density) + val geometry = geometryTerms.geometry + val baseStyle = TextStyle( + fontSize = settings.fontSize.sp, + lineHeight = (settings.fontSize * settings.lineSpacing).sp, + fontFamily = fontFamily, + textAlign = settings.textAlign.toComposeTextAlign() + ).withAndroidPaginationTextMetrics() + logEpubPagination { + "chapter_window_start book=\"${book.title.logPreview()}\" chapter=$chapterIndex " + + "firstPage=${firstPageIndex + 1} viewport=${viewport.widthPx}x${viewport.heightPx} " + + "page=${geometry.pageWidthPx}x${geometry.pageHeightPx}" + } + val pages = paginateChapter( + chapter = chapter, + chapterIndex = chapterIndex, + firstPageIndex = firstPageIndex, + settings = settings, + geometry = geometry, + baseStyle = baseStyle + ).mapIndexed { index, page -> page.copy(pageIndex = firstPageIndex + index) } + logEpubPagination { + "chapter_window_complete book=\"${book.title.logPreview()}\" chapter=$chapterIndex " + + "pages=${pages.size} firstPage=${firstPageIndex + 1}" + } + return pages + } + private suspend fun paginateChapter( chapter: SharedEpubChapter, chapterIndex: Int, @@ -190,6 +233,13 @@ class SharedMeasuredEpubPaginator( "blocks=${pageBlocks.size} range=${page.startOffset}..${page.endOffset} " + "textChars=${page.text.length} tail=\"${pageBlockFits.measuredPageFitTail()}\"" } + logEpubCutoff { + "cutoff_probe layer=measured_overflow reason=$reason page=${page.pageIndex + 1} chapter=$chapterIndex " + + "usedPx=$usedHeight pageHeightPx=${geometry.pageHeightPx} remainingPx=$remainingPx " + + "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=${pageBlocks.size} " + + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} " + + "tail=\"${pageBlockFits.measuredPageFitTail()}\"" + } } logReaderGapPagination { val firstTopMargin = pageBlocks.firstOrNull()?.effectiveTopMarginPx() ?: 0 @@ -206,8 +256,11 @@ class SharedMeasuredEpubPaginator( usedHeight = 0 } + var processedBlocks = 0 while (queue.isNotEmpty()) { currentCoroutineContext().ensureActive() + processedBlocks += 1 + if (processedBlocks % 8 == 0) yield() val block = queue.removeFirst() val blockHeight = measureBlock(block, geometry, baseStyle, settings) val spaceBeforeBlock = block.collapsedMarginBefore(pageBlocks.lastOrNull(), settings) @@ -411,6 +464,7 @@ class SharedMeasuredEpubPaginator( style: TextStyle, widthPx: Int ): TextLayoutResult { + currentCoroutineContext().ensureActive() return withContext(Dispatchers.Main) { textMeasurer.measure( text = text, @@ -736,26 +790,60 @@ internal data class MeasuredPageGeometry( viewport: ReaderViewportSpec, densityScale: Float = 1f ): MeasuredPageGeometry { - val safeWidth = viewport.widthPx.takeIf { it > 0 } ?: 980 - val safeHeight = viewport.heightPx.takeIf { it > 0 } ?: 720 - val scale = densityScale.takeIf { it.isFinite() && it > 0f } ?: 1f - val gutter = if (settings.isTwoPageSpreadEnabled()) MeasuredSpreadGutterPx.scaleCssPx(scale) else 0 - val horizontalMargin = settings.resolvedHorizontalMargin.scaleCssPx(scale) * 2 - val verticalMargin = settings.resolvedVerticalMargin.scaleCssPx(scale) * 2 - val contentWidth = (safeWidth - horizontalMargin).coerceAtLeast(1) - val configuredPageWidth = settings.pageWidth.scaleCssPx(scale).coerceAtLeast(1) - val pageWidth = if (settings.isTwoPageSpreadEnabled()) { - val spreadWidth = contentWidth.coerceAtMost((configuredPageWidth * 2) + gutter) - ((spreadWidth - gutter).coerceAtLeast(1) / 2).coerceAtLeast(1) - } else { - contentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) - } - val pageHeight = (safeHeight - verticalMargin).coerceAtLeast(1) - return MeasuredPageGeometry(pageWidthPx = pageWidth, pageHeightPx = pageHeight) + return measuredPageGeometryTerms(settings, viewport, densityScale).geometry } } } +private data class MeasuredPageGeometryTerms( + val safeWidthPx: Int, + val safeHeightPx: Int, + val pageHorizontalMarginPx: Int, + val pageVerticalMarginPx: Int, + val configuredPageWidthPx: Int, + val spreadGutterPx: Int, + val singlePageContentWidthPx: Int, + val twoPageAvailableOuterWidthPx: Int, + val twoPageAvailableContentWidthPx: Int, + val geometry: MeasuredPageGeometry +) + +private fun measuredPageGeometryTerms( + settings: ReaderSettings, + viewport: ReaderViewportSpec, + densityScale: Float = 1f +): MeasuredPageGeometryTerms { + val safeWidth = viewport.widthPx.takeIf { it > 0 } ?: 980 + val safeHeight = viewport.heightPx.takeIf { it > 0 } ?: 720 + val scale = densityScale.takeIf { it.isFinite() && it > 0f } ?: 1f + val pageHorizontalMargin = settings.resolvedHorizontalMargin.scaleCssPx(scale) + val pageVerticalMargin = settings.resolvedVerticalMargin.scaleCssPx(scale) + val configuredPageWidth = settings.pageWidth.scaleCssPx(scale).coerceAtLeast(1) + val usesSpreadPageSlot = settings.usesMeasuredPaginatedSpreadPageSlot() + val gutter = if (usesSpreadPageSlot) MeasuredSpreadGutterPx.scaleCssPx(scale) else 0 + val singlePageContentWidth = (safeWidth - (pageHorizontalMargin * 2)).coerceAtLeast(1) + val twoPageAvailableOuterWidth = ((safeWidth - gutter).coerceAtLeast(1) / 2).coerceAtLeast(1) + val twoPageAvailableContentWidth = (twoPageAvailableOuterWidth - (pageHorizontalMargin * 2)).coerceAtLeast(1) + val pageWidth = if (usesSpreadPageSlot) { + twoPageAvailableContentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) + } else { + singlePageContentWidth.coerceAtMost(configuredPageWidth).coerceAtLeast(1) + } + val pageHeight = (safeHeight - (pageVerticalMargin * 2)).coerceAtLeast(1) + return MeasuredPageGeometryTerms( + safeWidthPx = safeWidth, + safeHeightPx = safeHeight, + pageHorizontalMarginPx = pageHorizontalMargin, + pageVerticalMarginPx = pageVerticalMargin, + configuredPageWidthPx = configuredPageWidth, + spreadGutterPx = gutter, + singlePageContentWidthPx = singlePageContentWidth, + twoPageAvailableOuterWidthPx = twoPageAvailableOuterWidth, + twoPageAvailableContentWidthPx = twoPageAvailableContentWidth, + geometry = MeasuredPageGeometry(pageWidthPx = pageWidth, pageHeightPx = pageHeight) + ) +} + internal fun measuredPageGeometryFor( settings: ReaderSettings, viewport: ReaderViewportSpec, @@ -766,6 +854,10 @@ internal fun measuredPageGeometryFor( private const val MeasuredSpreadGutterPx = 28 +private fun ReaderSettings.usesMeasuredPaginatedSpreadPageSlot(): Boolean { + return readingMode == ReaderReadingMode.PAGINATED +} + private fun Int.scaleCssPx(scale: Float): Int { return (this * scale).roundToInt() } @@ -1146,6 +1238,12 @@ private fun logOversizedMeasuredPageFit( "usedPx=$usedPx pageHeightPx=$pageHeightPx remainingPx=$remainingPx blocks=1 " + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} tail=\"${fit.format()}\"" } + logEpubCutoff { + "cutoff_probe layer=measured_overflow reason=$reason page=${page.pageIndex + 1} chapter=$chapterIndex " + + "usedPx=$usedPx pageHeightPx=$pageHeightPx remainingPx=$remainingPx " + + "overflowPx=${(-remainingPx).coerceAtLeast(0)} blocks=1 " + + "range=${page.startOffset}..${page.endOffset} textChars=${page.text.length} tail=\"${fit.format()}\"" + } } private fun String.toCssPxOrNull(containerPx: Int): Int? { @@ -1191,6 +1289,10 @@ private inline fun logEpubPageFit(message: () -> String) { logSharedReaderDiagnostic("EpistemeEpubPageFit", message) } +private inline fun logEpubCutoff(message: () -> String) { + logSharedReaderDiagnostic(SharedEpubCutoffDiagnosticsTag, message) +} + private inline fun logReaderGapPagination(message: () -> String) { logSharedReaderDiagnostic("EpistemeReaderGap", message) }