From 8366d76dcd7bc3367a4caa5eb243a530f7efdd06 Mon Sep 17 00:00:00 2001
From: Aryan
Date: Sun, 10 May 2026 10:07:37 +0530
Subject: [PATCH] Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats
* Centralize library management logic and introduce support for plain text and HTML formats
* Expand unit test coverage for library state management, UI models, and MainViewModel features.
* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence
* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.
* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin
* Add comprehensive unit tests
* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.
* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version
* Folder import support for desktop app
* Introduce Smart Shelves with rule-based filtering in desktop version
* Implement shared EPUB annotation serialization and highlight rendering
* Centralize file type capabilities and platform-specific support logic
* Refactor reader state management to use a central reducer
* Implement customizable reader toolbar and advanced formatting settings in shared
* Implement locator-based navigation and customizable highlight palette for desktop app
* Enhance reader customization and expand search functionality in desktop app
* Redesign reader settings and tools into a tabbed control panel in desktop app
* Enhance reader navigation and highlight precision in desktop app
* Implement bidirectional position synchronization and dynamic highlights in the desktop reader
* Implement shared state management and enhanced search for the PDF reader in desktop app
* Add vertical scroll support to the desktop PDF reader
* Implement ink, text, and eraser annotation support in desktop PDF viewer
* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app
* Implement link handling and navigation for PDF and EPUB readers in desktop app
* Implement PDF jump history for navigation in desktop app
* Enhance PDF ink rendering and annotation capabilities in desktop app
* Implement advanced PDF text annotations with inline editing and rich styling in desktop app
* Add move handle and movement logic for PDF text annotations in desktop app
* Implement local folder synchronization and metadata sidecar support in desktop app
* Implement book metadata extraction and drag-and-drop import for Desktop
* Implement dynamic and custom app theme management for desktop
* Introduce canonical PDF annotation codec and support for multi-segment highlights
* Implement rich text editing and pagination support for the PDF reader in desktop app
* Improve PDF rich text pagination, synchronization, and observability in desktop
* Hide trailing structural page breaks in rich text editor
* Implement a unified JVM book loader and expand supported formats on Desktop
* Add comic archive support for Desktop and enhance MOBI parsing
* Implement shared OPDS catalog support and UI for Android and Desktop
* Improve native WebView lifecycle and surface transition management on Desktop
* Enable Compose Swing interop blending and simplify Desktop WebView management
* Integrate BYOK AI features and Cloud TTS for desktop
* Enhance Desktop TTS with streaming audio and improved secure storage for AI key
* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app
* Implement custom font management and utility screens in desktop app
* Implement PDFium-based PDF annotation export
* Remove PdfBox dependency and standardize PDF export via Pdfium
* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app
* Implement reader themes and custom texture support in desktop app
* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app
* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app
* Exclude manual-only files from automated sync and import
* Implement customizable Text-to-Speech (TTS) word replacements
* Optimize reader performance with persistent layout caching and decoupled theme rendering
* Improve position restoration during reader reconfiguration in epub pagination
* Use independent thickness for eraser tool and stylus override
---
app/build.gradle.kts | 20 +-
app/src/main/cpp/pdfium_bridge.cpp | 1195 +++
.../java/com/aryan/reader/FileTypeResolver.kt | 36 +
.../java/com/aryan/reader/FolderSyncWorker.kt | 30 +-
.../java/com/aryan/reader/LibraryScreen.kt | 7 +-
.../com/aryan/reader/LibraryStateProjector.kt | 4 +-
.../java/com/aryan/reader/MainViewModel.kt | 214 +-
.../com/aryan/reader/SharedModelMappers.kt | 26 +-
.../com/aryan/reader/TtsReplacementStore.kt | 43 +
.../aryan/reader/TtsWordReplacementsSheet.kt | 667 ++
.../reader/data/RecentFilesRepository.kt | 37 +-
.../reader/data/SmartCollectionEngine.kt | 85 +-
.../java/com/aryan/reader/epub/EpubParser.kt | 107 +-
.../java/com/aryan/reader/epub/Fb2Parser.kt | 6 +-
.../aryan/reader/epub/ImportedFileCache.kt | 10 +-
.../java/com/aryan/reader/epub/MobiParser.kt | 6 +-
.../java/com/aryan/reader/epub/OdtParser.kt | 6 +-
.../aryan/reader/epub/SingleFileImporter.kt | 85 +-
.../epubreader/EpubReaderAnnotations.kt | 191 +-
.../reader/epubreader/EpubReaderControls.kt | 21 +-
.../reader/epubreader/EpubReaderDrawer.kt | 5 +-
.../reader/epubreader/EpubReaderScreen.kt | 123 +-
.../aryan/reader/epubreader/EpubReaderTts.kt | 30 +-
.../java/com/aryan/reader/opds/OpdsModels.kt | 101 +-
.../java/com/aryan/reader/opds/OpdsParser.kt | 485 +-
.../com/aryan/reader/opds/OpdsRepository.kt | 123 +-
.../com/aryan/reader/opds/OpdsViewModel.kt | 66 +-
.../AndroidHtmlParserPlatform.kt | 6 +-
.../reader/paginatedreader/BookPaginator.kt | 417 +-
.../reader/paginatedreader/ContentStyler.kt | 18 +-
.../aryan/reader/paginatedreader/Locator.kt | 68 +-
.../reader/paginatedreader/PaginatedReader.kt | 159 +-
.../PaginatedReaderViewModel.kt | 25 +-
.../PaginatedReconfiguration.kt | 6 +
.../aryan/reader/paginatedreader/Paginator.kt | 90 +-
.../paginatedreader/RenderThemeApplier.kt | 265 +
.../paginatedreader/data/BookCacheDatabase.kt | 177 +-
.../paginatedreader/data/BookCacheEntities.kt | 121 +-
.../data/BookProcessingWorker.kt | 50 +-
.../aryan/reader/pdf/NativePdfiumBridge.kt | 32 +
.../java/com/aryan/reader/pdf/PdfExporter.kt | 1057 ---
.../java/com/aryan/reader/pdf/PdfModels.kt | 2 +-
.../com/aryan/reader/pdf/PdfPageComposable.kt | 24 +-
.../com/aryan/reader/pdf/PdfPreferences.kt | 9 +-
.../java/com/aryan/reader/pdf/PdfToolbars.kt | 10 +
.../com/aryan/reader/pdf/PdfVerticalReader.kt | 11 +-
.../com/aryan/reader/pdf/PdfViewerScreen.kt | 185 +-
.../reader/pdf/PdfiumAnnotationExporter.kt | 768 ++
.../com/aryan/reader/pdf/RichTextSystem.kt | 348 +-
.../com/aryan/reader/pdf/UniversalDocument.kt | 12 +-
.../com/aryan/reader/tts/TtsController.kt | 2 +
.../aryan/reader/tts/TtsPlaybackManager.kt | 40 +-
app/src/main/res/values/strings.xml | 2 +
.../java/com/aryan/reader/FileHasherTest.kt | 54 +
.../com/aryan/reader/FileTypeResolverTest.kt | 23 +
.../aryan/reader/LibraryStateProjectorTest.kt | 514 ++
.../com/aryan/reader/MainViewModelTest.kt | 838 +-
.../aryan/reader/NonReaderScreenModelsTest.kt | 147 +
.../aryan/reader/TtsReplacementChunkTest.kt | 46 +
.../reader/data/FolderBookMetadataTest.kt | 90 +
.../data/RecentFileDaoReadingPositionTest.kt | 138 +
...ecentFileItemReadingPositionMappingTest.kt | 54 +
...FilesRepositoryReadingPositionMergeTest.kt | 148 +
.../reader/data/SmartCollectionEngineTest.kt | 143 +
.../aryan/reader/epub/EpubParserUnitTest.kt | 452 +
.../reader/epub/ImportedFileCacheTest.kt | 119 +
.../reader/epub/SingleFileImporterTest.kt | 146 +
.../EpubReaderBridgeAndControlsTest.kt | 200 +
.../epubreader/EpubReaderContentTest.kt | 161 +
...EpubReaderPreferencesAndAnnotationsTest.kt | 352 +
.../reader/epubreader/EpubReaderSearchTest.kt | 271 +
.../epubreader/TestSharedPreferences.kt | 57 +
.../com/aryan/reader/opds/OpdsParserTest.kt | 208 +
.../aryan/reader/opds/OpdsRepositoryTest.kt | 134 +
.../reader/paginatedreader/CfiUtilsTest.kt | 30 +
.../paginatedreader/ContentStylerTest.kt | 201 +
.../paginatedreader/CssParserThemeModeTest.kt | 51 +
.../paginatedreader/LocatorConverterTest.kt | 277 +
.../paginatedreader/PageCountEstimatorTest.kt | 81 +
.../PaginatedReconfigurationTest.kt | 43 +
.../paginatedreader/RenderThemeApplierTest.kt | 75 +
.../paginatedreader/data/BookCacheDaoTest.kt | 167 +
.../reader/pdf/PdfReaderCoreLogicTest.kt | 229 +
.../reader/pdf/PdfReaderPreferencesTest.kt | 219 +
.../reader/pdf/PdfReaderRepositoryTest.kt | 165 +
.../aryan/reader/pdf/PdfReaderRichTextTest.kt | 240 +
.../reader/pdf/PdfReaderSerializerTest.kt | 218 +
.../PdfReaderSettingsAndSharedModelsTest.kt | 147 +
.../aryan/reader/pdf/PdfTextRepositoryTest.kt | 139 +
.../pdf/PdfiumAnnotationExporterTest.kt | 171 +
build.gradle.kts | 1 +
desktopApp/build.gradle.kts | 8 +
.../reader/desktop/DesktopAiByokStore.kt | 527 ++
.../reader/desktop/DesktopByokAiAdapter.kt | 310 +
.../reader/desktop/DesktopComicArchive.kt | 586 ++
.../reader/desktop/DesktopCustomFontStore.kt | 143 +
.../aryan/reader/desktop/DesktopEpubLoader.kt | 276 +-
.../desktop/DesktopFolderMetadataExtractor.kt | 530 ++
.../desktop/DesktopGeminiCloudTtsAdapter.kt | 732 ++
.../reader/desktop/DesktopLibraryDatabase.kt | 192 +-
.../reader/desktop/DesktopLocalFolderSync.kt | 573 ++
.../reader/desktop/DesktopOpdsRepository.kt | 190 +
.../com/aryan/reader/desktop/DesktopPdfium.kt | 1131 ++-
.../com/aryan/reader/desktop/DesktopTtsLog.kt | 19 +
.../kotlin/com/aryan/reader/desktop/Main.kt | 7764 ++++++++++++++---
.../desktopMain/resources/google_fonts.json | 2083 +++++
.../resources/textures/classy_fabric.webp | Bin 0 -> 1944 bytes
.../resources/textures/ep_naturalblack.webp | Bin 0 -> 19270 bytes
.../resources/textures/ep_naturalwhite.webp | Bin 0 -> 15394 bytes
.../resources/textures/grey_wash_wall.webp | Bin 0 -> 7450 bytes
.../resources/textures/light-veneer.webp | Bin 0 -> 2606 bytes
.../resources/textures/retina_wood.webp | Bin 0 -> 9196 bytes
.../resources/textures/retro_intro.webp | Bin 0 -> 1620 bytes
.../resources/textures/texture_canvas.png | Bin 0 -> 80065 bytes
.../resources/textures/texture_eink.webp | Bin 0 -> 15394 bytes
.../resources/textures/texture_paper.png | Bin 0 -> 56983 bytes
.../resources/textures/texture_slate.png | Bin 0 -> 81533 bytes
.../reader/desktop/DesktopAiByokStoreTest.kt | 106 +
.../reader/desktop/DesktopComicArchiveTest.kt | 59 +
.../desktop/DesktopComposeInteropTest.kt | 55 +
.../desktop/DesktopCustomFontStoreTest.kt | 89 +
.../DesktopFolderMetadataExtractorTest.kt | 184 +
.../desktop/DesktopOpdsRepositoryTest.kt | 56 +
shared/build.gradle.kts | 2 +
.../reader/shared/LocalFolderSync.android.kt | 8 +
.../shared/ui/LocalBookCoverImage.android.kt | 28 +
.../aryan/reader/paginatedreader/CssParser.kt | 52 +-
.../com/aryan/reader/shared/AppActions.kt | 44 +
.../com/aryan/reader/shared/AppModels.kt | 5 +-
.../aryan/reader/shared/CustomFontModels.kt | 12 +
.../aryan/reader/shared/FileCapabilities.kt | 180 +
.../com/aryan/reader/shared/LibraryModels.kt | 13 +-
.../aryan/reader/shared/LibraryMutations.kt | 293 +
.../aryan/reader/shared/LibraryProjector.kt | 27 +-
.../reader/shared/LibraryStateProjector.kt | 49 +-
.../aryan/reader/shared/LocalFolderSync.kt | 507 ++
.../reader/shared/ReaderAnnotationModels.kt | 139 +-
.../shared/ReaderAnnotationSerializer.kt | 268 +
.../reader/shared/ReaderAppearanceModels.kt | 155 +-
.../aryan/reader/shared/ReaderExtrasModels.kt | 734 ++
.../reader/shared/ReaderMarkdownModels.kt | 114 +
.../reader/shared/ReaderToolbarModels.kt | 91 +
.../reader/shared/ReaderTtsReplacements.kt | 353 +
.../reader/shared/RepositoryContracts.kt | 5 +-
.../aryan/reader/shared/SharedFormatters.kt | 8 +
.../reader/shared/SharedLibrarySnapshot.kt | 618 ++
.../com/aryan/reader/shared/SharedReducers.kt | 118 +
.../reader/shared/SmartCollectionEngine.kt | 97 +
.../reader/shared/opds/SharedOpdsCatalogs.kt | 144 +
.../shared/opds/SharedOpdsController.kt | 162 +
.../reader/shared/opds/SharedOpdsModels.kt | 130 +
.../reader/shared/opds/SharedOpdsUtilities.kt | 206 +
.../reader/shared/pdf/PdfInteractionModels.kt | 121 +-
.../reader/shared/pdf/PdfReaderSession.kt | 573 ++
.../reader/shared/pdf/PdfSelectionGeometry.kt | 172 +
.../reader/shared/pdf/PdfVerticalLayout.kt | 30 +
.../pdf/SharedPdfAnnotationSidecarCodec.kt | 415 +
.../shared/pdf/SharedPdfInkRendering.kt | 412 +
.../reader/shared/pdf/SharedPdfRichText.kt | 1743 ++++
.../shared/pdf/SharedPdfTextAnnotations.kt | 353 +
.../reader/shared/reader/ReaderEngine.kt | 723 +-
.../reader/ReaderHtmlDocumentBuilder.kt | 1933 +++-
.../reader/shared/reader/ReaderModels.kt | 30 +-
.../shared/reader/SharedTextBookFactory.kt | 106 +
.../reader/shared/reader/SimplePaginator.kt | 4 +-
.../reader/shared/ui/LocalBookCoverImage.kt | 11 +
.../reader/shared/ui/NonReaderLayoutModels.kt | 147 +
.../reader/shared/ui/NonReaderScreens.kt | 1711 +++-
.../reader/shared/ui/ReaderWorkspaceModels.kt | 235 +
.../reader/shared/ui/ReaderWorkspaceShell.kt | 224 +
.../aryan/reader/shared/ui/SharedAppShell.kt | 499 ++
.../shared/ui/SharedAppThemeSettings.kt | 980 +++
.../reader/shared/ui/SharedLibraryDialogs.kt | 242 +
.../reader/shared/ui/SharedMarkdownText.kt | 194 +
.../reader/shared/ui/SharedOpdsScreen.kt | 841 ++
.../reader/shared/ui/SharedPdfAnnotationUi.kt | 1404 +++
.../reader/shared/ui/SharedPdfRichTextUi.kt | 261 +
.../reader/shared/ui/SharedReaderChrome.kt | 2345 +++++
.../reader/shared/ui/SharedUtilityScreens.kt | 600 ++
.../shared/EpubAnnotationSerializerTest.kt | 176 +
.../reader/shared/FileCapabilitiesTest.kt | 78 +
.../shared/LocalFolderSyncEngineTest.kt | 285 +
.../reader/shared/ReaderActionReducerTest.kt | 327 +
.../shared/ReaderAppearanceModelsTest.kt | 56 +
.../reader/shared/ReaderExtrasModelsTest.kt | 287 +
.../reader/shared/ReaderMarkdownParserTest.kt | 31 +
.../shared/ReaderToolbarPreferencesTest.kt | 51 +
.../shared/ReaderTtsReplacementEngineTest.kt | 156 +
.../shared/SharedAppThemeReducerTest.kt | 61 +
.../reader/shared/SharedLibraryEditorTest.kt | 229 +
.../shared/SharedLibraryProjectorTest.kt | 373 +
.../shared/SharedLibrarySnapshotJsonTest.kt | 204 +
.../shared/SmartCollectionEngineTest.kt | 142 +
.../shared/opds/SharedOpdsCatalogsTest.kt | 107 +
.../reader/shared/pdf/PdfReaderSessionTest.kt | 308 +
.../shared/pdf/PdfSelectionGeometryTest.kt | 67 +
.../pdf/SharedPdfAnnotationSerializerTest.kt | 216 +
.../shared/pdf/SharedPdfInkRenderingTest.kt | 114 +
.../shared/pdf/SharedPdfRichTextTest.kt | 246 +
.../pdf/SharedPdfTextAnnotationsTest.kt | 222 +
.../reader/shared/reader/ReaderEngineTest.kt | 190 +
.../reader/ReaderHtmlDocumentBuilderTest.kt | 377 +
.../shared/ui/NonReaderLayoutModelsTest.kt | 166 +
.../shared/ui/ReaderWorkspaceModelsTest.kt | 166 +
.../shared/ui/SharedAppThemeColorMathTest.kt | 56 +
.../reader/shared/LocalFolderSync.desktop.kt | 8 +
.../shared/ui/LocalBookCoverImage.desktop.kt | 36 +
.../shared/ReaderTtsFileCacheManagerTest.kt | 49 +
.../shared/opds/SharedOpdsParserTest.kt | 181 +
.../shared/reader/SharedJvmBookLoaderTest.kt | 201 +
.../reader/paginatedreader/HtmlParser.kt | 22 +-
.../shared/ReaderTtsFileCacheManager.kt | 177 +
.../reader/shared/opds/SharedOpdsParser.kt | 447 +
.../shared/reader/SharedJvmBookLoader.kt | 1396 +++
214 files changed, 53372 insertions(+), 4702 deletions(-)
create mode 100644 app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
create mode 100644 app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt
create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt
create mode 100644 app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt
create mode 100644 app/src/test/java/com/aryan/reader/FileHasherTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/epubreader/TestSharedPreferences.kt
create mode 100644 app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/CssParserThemeModeTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PageCountEstimatorTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PaginatedReconfigurationTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/RenderThemeApplierTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt
create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfiumAnnotationExporterTest.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt
create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt
create mode 100644 desktopApp/src/desktopMain/resources/google_fonts.json
create mode 100644 desktopApp/src/desktopMain/resources/textures/classy_fabric.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/light-veneer.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/retina_wood.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/retro_intro.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_canvas.png
create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_eink.webp
create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_paper.png
create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_slate.png
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt
create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt
create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt
create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedTextBookFactory.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedMarkdownText.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfRichTextUi.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt
create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderAppearanceModelsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderMarkdownParserTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderToolbarPreferencesTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderTtsReplacementEngineTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedAppThemeReducerTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryEditorTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SmartCollectionEngineTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometryTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRenderingTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt
create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt
create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/LocalFolderSync.desktop.kt
create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt
create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt
create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsParserTest.kt
create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt
create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManager.kt
create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsParser.kt
create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9e2d5a2..a4c5992 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,6 +9,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1"
+ alias(libs.plugins.kover)
}
val localProperties = Properties()
@@ -150,6 +151,22 @@ android {
}
}
}
+
+kover {
+ reports {
+ filters {
+ excludes {
+ classes(
+ "*.BuildConfig",
+ "*.ComposableSingletons*",
+ "*_Impl",
+ "*Database_Impl",
+ "*Dao_Impl"
+ )
+ }
+ }
+ }
+}
//noinspection UseTomlInstead
dependencies {
@@ -220,7 +237,6 @@ dependencies {
implementation("com.jakewharton.timber:timber:5.0.1")
- implementation("com.tom-roush:pdfbox-android:2.0.27.0")
implementation("me.zhanghai.android.libarchive:library:1.1.6")
implementation("androidx.paging:paging-runtime-ktx:3.3.6")
@@ -252,6 +268,8 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk-android:1.14.9")
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation("org.json:json:20251224")
+ testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("org.slf4j:slf4j-nop:2.0.17")
}
diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp
index c389fc7..e56b5fe 100644
--- a/app/src/main/cpp/pdfium_bridge.cpp
+++ b/app/src/main/cpp/pdfium_bridge.cpp
@@ -1,6 +1,11 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -11,6 +16,34 @@
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
+struct FS_RECTF_BRIDGE {
+ float left;
+ float top;
+ float right;
+ float bottom;
+};
+
+struct FS_POINTF_BRIDGE {
+ float x;
+ float y;
+};
+
+struct FS_QUADPOINTSF_BRIDGE {
+ float x1;
+ float y1;
+ float x2;
+ float y2;
+ float x3;
+ float y3;
+ float x4;
+ float y4;
+};
+
+struct FPDF_FILEWRITE_BRIDGE {
+ int version;
+ int (*WriteBlock)(FPDF_FILEWRITE_BRIDGE* self, const void* data, unsigned long size);
+};
+
typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags);
@@ -25,6 +58,7 @@ typedef int (*FPDFPage_CountObjects_t)(void* page);
typedef void* (*FPDFPage_GetObject_t)(void* page, int index);
typedef int (*FPDFPageObj_GetType_t)(void* page_object);
typedef void* (*FPDFImageObj_GetBitmap_t)(void* image_object);
+typedef void* (*FPDFBitmap_CreateEx_t)(int width, int height, int format, void* first_scan, int stride);
typedef int (*FPDFBitmap_GetWidth_t)(void* bitmap);
typedef int (*FPDFBitmap_GetHeight_t)(void* bitmap);
typedef int (*FPDFBitmap_GetStride_t)(void* bitmap);
@@ -45,6 +79,42 @@ typedef void* (*FPDFLink_GetDest_t)(void* document, void* link);
typedef void* (*FPDFAction_GetDest_t)(void* document, void* action);
typedef int (*FPDFDest_GetDestPageIndex_t)(void* document, void* dest);
typedef unsigned long (*FPDFAction_GetFilePath_t)(void* action, void* buffer, unsigned long buflen);
+typedef void* (*FPDF_LoadDocument_t)(const char* file_path, const char* password);
+typedef void (*FPDF_CloseDocument_t)(void* document);
+typedef int (*FPDF_GetPageCount_t)(void* document);
+typedef void* (*FPDF_LoadPage_t)(void* document, int page_index);
+typedef void (*FPDF_ClosePage_t)(void* page);
+typedef float (*FPDF_GetPageWidthF_t)(void* page);
+typedef float (*FPDF_GetPageHeightF_t)(void* page);
+typedef double (*FPDF_GetPageWidth_t)(void* page);
+typedef double (*FPDF_GetPageHeight_t)(void* page);
+typedef void* (*FPDFPage_CreateAnnot_t)(void* page, int subtype);
+typedef int (*FPDFAnnot_SetRect_t)(void* annot, const FS_RECTF_BRIDGE* rect);
+typedef int (*FPDFAnnot_SetColor_t)(void* annot, int type, unsigned int R, unsigned int G, unsigned int B, unsigned int A);
+typedef int (*FPDFAnnot_SetBorder_t)(void* annot, float horizontal_radius, float vertical_radius, float border_width);
+typedef int (*FPDFAnnot_SetStringValue_t)(void* annot, const char* key, const unsigned short* value);
+typedef int (*FPDFAnnot_AddInkStroke_t)(void* annot, const FS_POINTF_BRIDGE* points, size_t point_count);
+typedef int (*FPDFAnnot_AppendAttachmentPoints_t)(void* annot, const FS_QUADPOINTSF_BRIDGE* quad_points);
+typedef void (*FPDFPage_InsertObject_t)(void* page, void* page_object);
+typedef void* (*FPDFPageObj_NewImageObj_t)(void* document);
+typedef int (*FPDFImageObj_SetMatrix_t)(void* image_object, double a, double b, double c, double d, double e, double f);
+typedef int (*FPDFImageObj_SetBitmap_t)(void** pages, int nCount, void* image_object, void* bitmap);
+typedef void* (*FPDFPageObj_NewTextObj_t)(void* document, const char* font, float font_size);
+typedef void* (*FPDFPageObj_CreateTextObj_t)(void* document, void* font, float font_size);
+typedef void* (*FPDFText_LoadFont_t)(void* document, const unsigned char* data, unsigned int size, int font_type, int cid);
+typedef void* (*FPDFText_LoadStandardFont_t)(void* document, const char* font);
+typedef int (*FPDFText_SetText_t)(void* text_object, const unsigned short* text);
+typedef int (*FPDFPageObj_SetFillColor_t)(void* page_object, unsigned int R, unsigned int G, unsigned int B, unsigned int A);
+typedef int (*FPDFPageObj_SetStrokeColor_t)(void* page_object, unsigned int R, unsigned int G, unsigned int B, unsigned int A);
+typedef int (*FPDFPageObj_SetStrokeWidth_t)(void* page_object, float width);
+typedef void (*FPDFPageObj_Transform_t)(void* page_object, double a, double b, double c, double d, double e, double f);
+typedef void* (*FPDFPageObj_CreateNewRect_t)(float x, float y, float w, float h);
+typedef void* (*FPDFPageObj_CreateNewPath_t)(float x, float y);
+typedef int (*FPDFPath_LineTo_t)(void* path, float x, float y);
+typedef int (*FPDFPath_SetDrawMode_t)(void* path, int fillmode, int stroke);
+typedef void (*FPDFPageObj_Destroy_t)(void* page_object);
+typedef int (*FPDFPage_GenerateContent_t)(void* page);
+typedef int (*FPDF_SaveAsCopy_t)(void* document, FPDF_FILEWRITE_BRIDGE* file_write, unsigned long flags);
static FPDFLink_GetLinkAtPoint_t get_link_at_point_func = nullptr;
static FPDFAction_GetURIPath_t get_uri_path_func = nullptr;
@@ -62,6 +132,7 @@ static FPDFPage_CountObjects_t count_objects_func = nullptr;
static FPDFPage_GetObject_t get_object_func = nullptr;
static FPDFPageObj_GetType_t get_object_type_func = nullptr;
static FPDFImageObj_GetBitmap_t get_image_bitmap_func = nullptr;
+static FPDFBitmap_CreateEx_t bitmap_create_ex_func = nullptr;
static FPDFBitmap_GetWidth_t bitmap_get_width_func = nullptr;
static FPDFBitmap_GetHeight_t bitmap_get_height_func = nullptr;
static FPDFBitmap_GetStride_t bitmap_get_stride_func = nullptr;
@@ -88,6 +159,42 @@ typedef void (*FPDFPage_CloseAnnot_t)(void* annot);
static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr;
static FPDFPage_CloseAnnot_t close_annot_func = nullptr;
+static FPDF_LoadDocument_t load_document_func = nullptr;
+static FPDF_CloseDocument_t close_document_func = nullptr;
+static FPDF_GetPageCount_t get_page_count_func = nullptr;
+static FPDF_LoadPage_t load_page_func = nullptr;
+static FPDF_ClosePage_t close_page_func = nullptr;
+static FPDF_GetPageWidthF_t get_page_width_func = nullptr;
+static FPDF_GetPageHeightF_t get_page_height_func = nullptr;
+static FPDF_GetPageWidth_t get_page_width_double_func = nullptr;
+static FPDF_GetPageHeight_t get_page_height_double_func = nullptr;
+static FPDFPage_CreateAnnot_t create_annot_func = nullptr;
+static FPDFAnnot_SetRect_t set_annot_rect_func = nullptr;
+static FPDFAnnot_SetColor_t set_annot_color_func = nullptr;
+static FPDFAnnot_SetBorder_t set_annot_border_func = nullptr;
+static FPDFAnnot_SetStringValue_t set_annot_string_value_func = nullptr;
+static FPDFAnnot_AddInkStroke_t add_ink_stroke_func = nullptr;
+static FPDFAnnot_AppendAttachmentPoints_t append_attachment_points_func = nullptr;
+static FPDFPage_InsertObject_t insert_page_object_func = nullptr;
+static FPDFPageObj_NewImageObj_t new_image_object_func = nullptr;
+static FPDFImageObj_SetMatrix_t set_image_matrix_func = nullptr;
+static FPDFImageObj_SetBitmap_t set_image_bitmap_func = nullptr;
+static FPDFPageObj_NewTextObj_t new_text_object_func = nullptr;
+static FPDFPageObj_CreateTextObj_t create_text_object_func = nullptr;
+static FPDFText_LoadFont_t load_font_func = nullptr;
+static FPDFText_LoadStandardFont_t load_standard_font_func = nullptr;
+static FPDFText_SetText_t set_text_object_text_func = nullptr;
+static FPDFPageObj_SetFillColor_t set_page_object_fill_color_func = nullptr;
+static FPDFPageObj_SetStrokeColor_t set_page_object_stroke_color_func = nullptr;
+static FPDFPageObj_SetStrokeWidth_t set_page_object_stroke_width_func = nullptr;
+static FPDFPageObj_Transform_t transform_page_object_func = nullptr;
+static FPDFPageObj_CreateNewRect_t create_rect_object_func = nullptr;
+static FPDFPageObj_CreateNewPath_t create_path_object_func = nullptr;
+static FPDFPath_LineTo_t path_line_to_func = nullptr;
+static FPDFPath_SetDrawMode_t path_set_draw_mode_func = nullptr;
+static FPDFPageObj_Destroy_t destroy_page_object_func = nullptr;
+static FPDFPage_GenerateContent_t generate_content_func = nullptr;
+static FPDF_SaveAsCopy_t save_as_copy_func = nullptr;
static bool init_pdfium() {
if (pdfium_handle) return true;
@@ -115,6 +222,42 @@ static bool init_pdfium() {
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot");
get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags");
set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags");
+ load_document_func = (FPDF_LoadDocument_t) dlsym(pdfium_handle, "FPDF_LoadDocument");
+ close_document_func = (FPDF_CloseDocument_t) dlsym(pdfium_handle, "FPDF_CloseDocument");
+ get_page_count_func = (FPDF_GetPageCount_t) dlsym(pdfium_handle, "FPDF_GetPageCount");
+ load_page_func = (FPDF_LoadPage_t) dlsym(pdfium_handle, "FPDF_LoadPage");
+ close_page_func = (FPDF_ClosePage_t) dlsym(pdfium_handle, "FPDF_ClosePage");
+ get_page_width_func = (FPDF_GetPageWidthF_t) dlsym(pdfium_handle, "FPDF_GetPageWidthF");
+ get_page_height_func = (FPDF_GetPageHeightF_t) dlsym(pdfium_handle, "FPDF_GetPageHeightF");
+ get_page_width_double_func = (FPDF_GetPageWidth_t) dlsym(pdfium_handle, "FPDF_GetPageWidth");
+ get_page_height_double_func = (FPDF_GetPageHeight_t) dlsym(pdfium_handle, "FPDF_GetPageHeight");
+ create_annot_func = (FPDFPage_CreateAnnot_t) dlsym(pdfium_handle, "FPDFPage_CreateAnnot");
+ set_annot_rect_func = (FPDFAnnot_SetRect_t) dlsym(pdfium_handle, "FPDFAnnot_SetRect");
+ set_annot_color_func = (FPDFAnnot_SetColor_t) dlsym(pdfium_handle, "FPDFAnnot_SetColor");
+ set_annot_border_func = (FPDFAnnot_SetBorder_t) dlsym(pdfium_handle, "FPDFAnnot_SetBorder");
+ set_annot_string_value_func = (FPDFAnnot_SetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_SetStringValue");
+ add_ink_stroke_func = (FPDFAnnot_AddInkStroke_t) dlsym(pdfium_handle, "FPDFAnnot_AddInkStroke");
+ append_attachment_points_func = (FPDFAnnot_AppendAttachmentPoints_t) dlsym(pdfium_handle, "FPDFAnnot_AppendAttachmentPoints");
+ insert_page_object_func = (FPDFPage_InsertObject_t) dlsym(pdfium_handle, "FPDFPage_InsertObject");
+ new_image_object_func = (FPDFPageObj_NewImageObj_t) dlsym(pdfium_handle, "FPDFPageObj_NewImageObj");
+ set_image_matrix_func = (FPDFImageObj_SetMatrix_t) dlsym(pdfium_handle, "FPDFImageObj_SetMatrix");
+ set_image_bitmap_func = (FPDFImageObj_SetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_SetBitmap");
+ new_text_object_func = (FPDFPageObj_NewTextObj_t) dlsym(pdfium_handle, "FPDFPageObj_NewTextObj");
+ create_text_object_func = (FPDFPageObj_CreateTextObj_t) dlsym(pdfium_handle, "FPDFPageObj_CreateTextObj");
+ load_font_func = (FPDFText_LoadFont_t) dlsym(pdfium_handle, "FPDFText_LoadFont");
+ load_standard_font_func = (FPDFText_LoadStandardFont_t) dlsym(pdfium_handle, "FPDFText_LoadStandardFont");
+ set_text_object_text_func = (FPDFText_SetText_t) dlsym(pdfium_handle, "FPDFText_SetText");
+ set_page_object_fill_color_func = (FPDFPageObj_SetFillColor_t) dlsym(pdfium_handle, "FPDFPageObj_SetFillColor");
+ set_page_object_stroke_color_func = (FPDFPageObj_SetStrokeColor_t) dlsym(pdfium_handle, "FPDFPageObj_SetStrokeColor");
+ set_page_object_stroke_width_func = (FPDFPageObj_SetStrokeWidth_t) dlsym(pdfium_handle, "FPDFPageObj_SetStrokeWidth");
+ transform_page_object_func = (FPDFPageObj_Transform_t) dlsym(pdfium_handle, "FPDFPageObj_Transform");
+ create_rect_object_func = (FPDFPageObj_CreateNewRect_t) dlsym(pdfium_handle, "FPDFPageObj_CreateNewRect");
+ create_path_object_func = (FPDFPageObj_CreateNewPath_t) dlsym(pdfium_handle, "FPDFPageObj_CreateNewPath");
+ path_line_to_func = (FPDFPath_LineTo_t) dlsym(pdfium_handle, "FPDFPath_LineTo");
+ path_set_draw_mode_func = (FPDFPath_SetDrawMode_t) dlsym(pdfium_handle, "FPDFPath_SetDrawMode");
+ destroy_page_object_func = (FPDFPageObj_Destroy_t) dlsym(pdfium_handle, "FPDFPageObj_Destroy");
+ generate_content_func = (FPDFPage_GenerateContent_t) dlsym(pdfium_handle, "FPDFPage_GenerateContent");
+ save_as_copy_func = (FPDF_SaveAsCopy_t) dlsym(pdfium_handle, "FPDF_SaveAsCopy");
// --- Object & Bitmap Functions ---
count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects");
@@ -122,6 +265,7 @@ static bool init_pdfium() {
get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType");
get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds");
get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap");
+ bitmap_create_ex_func = (FPDFBitmap_CreateEx_t) dlsym(pdfium_handle, "FPDFBitmap_CreateEx");
bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth");
bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight");
bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride");
@@ -164,6 +308,28 @@ static bool init_pdfium() {
get_link_action_func, do_annot_action_func, get_widget_at_point_func);
}
+ if (!load_document_func || !load_page_func || !create_annot_func || !save_as_copy_func) {
+ LOGE("PdfiumExport: Missing export functions. LoadDoc=%p LoadPage=%p CreateAnnot=%p Save=%p",
+ load_document_func, load_page_func, create_annot_func, save_as_copy_func);
+ }
+
+ if (!insert_page_object_func || !set_text_object_text_func ||
+ !set_page_object_fill_color_func || !transform_page_object_func || !generate_content_func) {
+ LOGE("PdfiumExport: Missing text object functions. InsertObj=%p NewText=%p CreateText=%p SetText=%p Fill=%p Transform=%p Generate=%p",
+ insert_page_object_func, new_text_object_func, create_text_object_func,
+ set_text_object_text_func, set_page_object_fill_color_func,
+ transform_page_object_func, generate_content_func);
+ }
+
+ if (!insert_page_object_func || !new_image_object_func || !set_image_bitmap_func ||
+ !bitmap_create_ex_func || !bitmap_destroy_func || (!set_image_matrix_func && !transform_page_object_func) ||
+ !generate_content_func) {
+ LOGE("PdfiumExport: Missing raster image functions. InsertObj=%p NewImage=%p SetBitmap=%p SetMatrix=%p CreateBitmap=%p DestroyBitmap=%p Transform=%p Generate=%p",
+ insert_page_object_func, new_image_object_func, set_image_bitmap_func,
+ set_image_matrix_func, bitmap_create_ex_func, bitmap_destroy_func,
+ transform_page_object_func, generate_content_func);
+ }
+
return get_annot_count_func != nullptr;
}
@@ -386,6 +552,1035 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
return result;
}
+static constexpr int kPdfAnnotHighlight = 9;
+static constexpr int kPdfAnnotInk = 15;
+static constexpr int kAnnotColor = 0;
+static constexpr int kAnnotFlagPrint = 1 << 2;
+static constexpr unsigned long kPdfNoIncremental = 1 << 1;
+static constexpr int kTextFlagBold = 1;
+static constexpr int kTextFlagItalic = 1 << 1;
+static constexpr int kTextFlagUnderline = 1 << 2;
+static constexpr int kTextFlagStrikeThrough = 1 << 3;
+static constexpr int kTextFlagAbsoluteLine = 1 << 4;
+static constexpr int kPdfFontTrueType = 2;
+static constexpr int kPdfBitmapBgra = 4;
+
+struct PdfiumFileWriter {
+ FPDF_FILEWRITE_BRIDGE base;
+ FILE* file;
+};
+
+static int write_pdf_block(FPDF_FILEWRITE_BRIDGE* self, const void* data, unsigned long size) {
+ auto* writer = reinterpret_cast(self);
+ if (!writer || !writer->file || !data) return 0;
+ return fwrite(data, 1, size, writer->file) == size ? 1 : 0;
+}
+
+static std::vector read_int_array(JNIEnv* env, jintArray array) {
+ std::vector values;
+ if (!array) return values;
+ jsize length = env->GetArrayLength(array);
+ values.resize(length);
+ if (length > 0) env->GetIntArrayRegion(array, 0, length, values.data());
+ return values;
+}
+
+static std::vector read_float_array(JNIEnv* env, jfloatArray array) {
+ std::vector values;
+ if (!array) return values;
+ jsize length = env->GetArrayLength(array);
+ values.resize(length);
+ if (length > 0) env->GetFloatArrayRegion(array, 0, length, values.data());
+ return values;
+}
+
+static std::string jstring_to_utf8(JNIEnv* env, jstring value) {
+ if (!value) return "";
+ const char* chars = env->GetStringUTFChars(value, nullptr);
+ if (!chars) return "";
+ std::string result(chars);
+ env->ReleaseStringUTFChars(value, chars);
+ return result;
+}
+
+static std::vector read_string_array(JNIEnv* env, jobjectArray array) {
+ std::vector values;
+ if (!array) return values;
+ jsize length = env->GetArrayLength(array);
+ values.reserve(static_cast(length));
+ for (jsize i = 0; i < length; i++) {
+ auto value = static_cast(env->GetObjectArrayElement(array, i));
+ values.push_back(jstring_to_utf8(env, value));
+ if (value) env->DeleteLocalRef(value);
+ }
+ return values;
+}
+
+static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) {
+ if (!set_annot_string_value_func || !annot || !key || !value) return false;
+ jsize length = env->GetStringLength(value);
+ const jchar* chars = env->GetStringChars(value, nullptr);
+ if (!chars) return false;
+
+ std::vector wide(static_cast(length) + 1);
+ for (jsize i = 0; i < length; i++) {
+ wide[static_cast(i)] = static_cast(chars[i]);
+ }
+ wide[static_cast(length)] = 0;
+ env->ReleaseStringChars(value, chars);
+
+ return set_annot_string_value_func(annot, key, wide.data()) != 0;
+}
+
+static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) {
+ if (!set_annot_string_value_func || !annot || !key) return false;
+ std::vector wide(value.size() + 1);
+ for (size_t i = 0; i < value.size(); i++) {
+ wide[i] = static_cast(static_cast(value[i]));
+ }
+ wide[value.size()] = 0;
+ return set_annot_string_value_func(annot, key, wide.data()) != 0;
+}
+
+static void argb_to_rgba(jint color, unsigned int* r, unsigned int* g, unsigned int* b, unsigned int* a) {
+ unsigned int argb = static_cast(color);
+ *a = (argb >> 24) & 0xFF;
+ *r = (argb >> 16) & 0xFF;
+ *g = (argb >> 8) & 0xFF;
+ *b = argb & 0xFF;
+}
+
+static float clamp_unit(float value) {
+ if (!std::isfinite(value)) return 0.0f;
+ if (value < 0.0f) return 0.0f;
+ if (value > 1.0f) return 1.0f;
+ return value;
+}
+
+static FS_RECTF_BRIDGE make_pdf_rect(float left, float top, float right, float bottom, float padding) {
+ float l = std::min(left, right) - padding;
+ float r = std::max(left, right) + padding;
+ float t = std::max(top, bottom) + padding;
+ float b = std::min(top, bottom) - padding;
+ return FS_RECTF_BRIDGE{l, t, r, b};
+}
+
+static float get_page_width_bridge(void* page) {
+ if (get_page_width_func) return get_page_width_func(page);
+ if (get_page_width_double_func) return static_cast(get_page_width_double_func(page));
+ return 0.0f;
+}
+
+static float get_page_height_bridge(void* page) {
+ if (get_page_height_func) return get_page_height_func(page);
+ if (get_page_height_double_func) return static_cast(get_page_height_double_func(page));
+ return 0.0f;
+}
+
+static bool validate_export_functions() {
+ return load_document_func &&
+ close_document_func &&
+ get_page_count_func &&
+ load_page_func &&
+ close_page_func &&
+ (get_page_width_func || get_page_width_double_func) &&
+ (get_page_height_func || get_page_height_double_func) &&
+ create_annot_func &&
+ close_annot_func &&
+ set_annot_rect_func &&
+ set_annot_color_func &&
+ set_annot_border_func &&
+ set_annot_string_value_func &&
+ add_ink_stroke_func &&
+ append_attachment_points_func &&
+ save_as_copy_func;
+}
+
+static bool validate_text_object_functions() {
+ return insert_page_object_func &&
+ (new_text_object_func || create_text_object_func) &&
+ set_text_object_text_func &&
+ set_page_object_fill_color_func &&
+ transform_page_object_func &&
+ generate_content_func;
+}
+
+static bool validate_raster_image_functions() {
+ return insert_page_object_func &&
+ new_image_object_func &&
+ set_image_bitmap_func &&
+ bitmap_create_ex_func &&
+ bitmap_destroy_func &&
+ (set_image_matrix_func || transform_page_object_func) &&
+ generate_content_func;
+}
+
+static std::vector> split_jstring_lines_wide(JNIEnv* env, jstring value) {
+ std::vector> lines;
+ lines.emplace_back();
+ if (!value) {
+ lines.back().push_back(0);
+ return lines;
+ }
+
+ jsize length = env->GetStringLength(value);
+ const jchar* chars = env->GetStringChars(value, nullptr);
+ if (!chars) {
+ lines.back().push_back(0);
+ return lines;
+ }
+
+ for (jsize i = 0; i < length; i++) {
+ jchar ch = chars[i];
+ if (ch == '\n') {
+ lines.emplace_back();
+ } else if (ch != '\r') {
+ lines.back().push_back(static_cast(ch));
+ }
+ }
+ env->ReleaseStringChars(value, chars);
+
+ for (auto& line : lines) {
+ line.push_back(0);
+ }
+ return lines;
+}
+
+static bool is_wide_space(unsigned short value) {
+ return value == static_cast(' ') ||
+ value == static_cast('\t') ||
+ value == static_cast('\v') ||
+ value == static_cast('\f');
+}
+
+static void push_wide_slice(
+ std::vector>& lines,
+ const std::vector& source,
+ size_t start,
+ size_t end) {
+ std::vector line;
+ if (start < end && start < source.size()) {
+ end = std::min(end, source.size());
+ line.insert(line.end(), source.begin() + static_cast(start), source.begin() + static_cast(end));
+ }
+ line.push_back(0);
+ lines.push_back(std::move(line));
+}
+
+static std::vector> wrap_wide_lines(
+ const std::vector>& source_lines,
+ float max_width,
+ float font_size,
+ bool preserve_lines) {
+ if (preserve_lines || max_width <= 1.0f || font_size <= 0.0f) {
+ return source_lines;
+ }
+
+ int max_chars = static_cast(std::floor(max_width / std::max(1.0f, font_size * 0.55f)));
+ max_chars = std::max(1, max_chars);
+
+ std::vector> wrapped;
+ for (const auto& source_line : source_lines) {
+ if (source_line.size() <= 1) {
+ wrapped.push_back(source_line);
+ continue;
+ }
+
+ size_t length = source_line.size() - 1;
+ size_t start = 0;
+ while (start < length) {
+ size_t end = std::min(length, start + static_cast(max_chars));
+ if (end < length) {
+ size_t break_at = end;
+ for (size_t pos = end; pos > start; pos--) {
+ if (is_wide_space(source_line[pos - 1])) {
+ break_at = pos;
+ break;
+ }
+ }
+ end = break_at;
+ }
+
+ if (end <= start) end = std::min(length, start + static_cast(max_chars));
+ push_wide_slice(wrapped, source_line, start, end);
+ start = end;
+ }
+ }
+
+ if (wrapped.empty()) {
+ wrapped.push_back(std::vector{0});
+ }
+ return wrapped;
+}
+
+static bool insert_page_object_or_destroy(void* page, void* object) {
+ if (!page || !object || !insert_page_object_func) {
+ if (object && destroy_page_object_func) destroy_page_object_func(object);
+ return false;
+ }
+ insert_page_object_func(page, object);
+ return true;
+}
+
+static std::vector read_file_bytes(const std::string& path) {
+ std::vector bytes;
+ if (path.empty()) return bytes;
+
+ FILE* file = fopen(path.c_str(), "rb");
+ if (!file) return bytes;
+ if (fseek(file, 0, SEEK_END) != 0) {
+ fclose(file);
+ return bytes;
+ }
+ long size = ftell(file);
+ if (size <= 0) {
+ fclose(file);
+ return bytes;
+ }
+ rewind(file);
+
+ bytes.resize(static_cast(size));
+ size_t read = fread(bytes.data(), 1, bytes.size(), file);
+ fclose(file);
+ if (read != bytes.size()) bytes.clear();
+ return bytes;
+}
+
+static const char* standard_font_name(const std::string& font_name, int flags) {
+ bool bold = (flags & kTextFlagBold) != 0;
+ bool italic = (flags & kTextFlagItalic) != 0;
+
+ if (font_name == "Serif") {
+ if (bold && italic) return "Times-BoldItalic";
+ if (bold) return "Times-Bold";
+ if (italic) return "Times-Italic";
+ return "Times-Roman";
+ }
+ if (font_name == "Monospace") {
+ if (bold && italic) return "Courier-BoldOblique";
+ if (bold) return "Courier-Bold";
+ if (italic) return "Courier-Oblique";
+ return "Courier";
+ }
+ if (bold && italic) return "Helvetica-BoldOblique";
+ if (bold) return "Helvetica-Bold";
+ if (italic || font_name == "Cursive") return "Helvetica-Oblique";
+ return "Helvetica";
+}
+
+static void* create_pdfium_text_object(
+ void* document,
+ const std::string& font_path,
+ const std::string& font_name,
+ float font_size,
+ int flags) {
+ if (create_text_object_func && load_font_func && !font_path.empty()) {
+ std::vector font_bytes = read_file_bytes(font_path);
+ if (!font_bytes.empty()) {
+ void* font = load_font_func(
+ document,
+ font_bytes.data(),
+ static_cast(font_bytes.size()),
+ kPdfFontTrueType,
+ 1
+ );
+ if (font) {
+ void* text_object = create_text_object_func(document, font, font_size);
+ if (text_object) return text_object;
+ }
+ }
+ }
+
+ const char* standard_name = standard_font_name(font_name, flags);
+ if (create_text_object_func && load_standard_font_func) {
+ void* font = load_standard_font_func(document, standard_name);
+ if (font) {
+ void* text_object = create_text_object_func(document, font, font_size);
+ if (text_object) return text_object;
+ }
+ }
+
+ if (new_text_object_func) {
+ return new_text_object_func(document, standard_name, font_size);
+ }
+ return nullptr;
+}
+
+static bool insert_background_rect_object(
+ void* page,
+ float left,
+ float bottom,
+ float width,
+ float height,
+ unsigned int r,
+ unsigned int g,
+ unsigned int b,
+ unsigned int a) {
+ if (!create_rect_object_func || !set_page_object_fill_color_func || !path_set_draw_mode_func) {
+ return false;
+ }
+ if (width <= 0.0f || height <= 0.0f || a == 0) return false;
+
+ void* background = create_rect_object_func(left, bottom, width, height);
+ if (!background) return false;
+ set_page_object_fill_color_func(background, r, g, b, a);
+ path_set_draw_mode_func(background, 1, 0);
+ return insert_page_object_or_destroy(page, background);
+}
+
+static bool insert_decoration_line_object(
+ void* page,
+ float x1,
+ float y,
+ float x2,
+ unsigned int r,
+ unsigned int g,
+ unsigned int b,
+ unsigned int a,
+ float stroke_width) {
+ if (!create_path_object_func || !path_line_to_func || !path_set_draw_mode_func ||
+ !set_page_object_stroke_color_func || !set_page_object_stroke_width_func) {
+ return false;
+ }
+
+ void* path = create_path_object_func(x1, y);
+ if (!path) return false;
+ path_line_to_func(path, x2, y);
+ set_page_object_stroke_color_func(path, r, g, b, a);
+ set_page_object_stroke_width_func(path, stroke_width);
+ path_set_draw_mode_func(path, 0, 1);
+ return insert_page_object_or_destroy(page, path);
+}
+
+static bool insert_text_line_object(
+ void* document,
+ void* page,
+ const std::vector& wide_line,
+ float x,
+ float y,
+ float font_size,
+ unsigned int r,
+ unsigned int g,
+ unsigned int b,
+ unsigned int a,
+ int flags,
+ const std::string& font_path,
+ const std::string& font_name) {
+ if (wide_line.size() <= 1) return true;
+
+ void* text_object = create_pdfium_text_object(document, font_path, font_name, font_size, flags);
+ if (!text_object) {
+ LOGE("PdfiumExport: Failed to create text object fontPath=%s fontName=%s size=%.2f",
+ font_path.c_str(), font_name.c_str(), font_size);
+ return false;
+ }
+ if (!set_text_object_text_func(text_object, wide_line.data())) {
+ LOGE("PdfiumExport: Failed to set text object text fontPath=%s fontName=%s chars=%zu",
+ font_path.c_str(), font_name.c_str(), wide_line.size() > 0 ? wide_line.size() - 1 : 0);
+ if (destroy_page_object_func) destroy_page_object_func(text_object);
+ return false;
+ }
+
+ set_page_object_fill_color_func(text_object, r, g, b, a);
+ float italicSkew = (flags & kTextFlagItalic) ? 0.22f : 0.0f;
+ transform_page_object_func(text_object, 1.0, 0.0, italicSkew, 1.0, x, y);
+
+ bool inserted = insert_page_object_or_destroy(page, text_object);
+ if (inserted && (flags & kTextFlagBold)) {
+ void* bold_object = create_pdfium_text_object(document, font_path, font_name, font_size, flags);
+ if (bold_object && set_text_object_text_func(bold_object, wide_line.data())) {
+ set_page_object_fill_color_func(bold_object, r, g, b, a);
+ transform_page_object_func(bold_object, 1.0, 0.0, italicSkew, 1.0, x + std::max(0.35f, font_size * 0.035f), y);
+ insert_page_object_or_destroy(page, bold_object);
+ } else if (bold_object && destroy_page_object_func) {
+ destroy_page_object_func(bold_object);
+ }
+ }
+
+ return inserted;
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
+ JNIEnv *env,
+ jclass clazz,
+ jstring sourcePath,
+ jstring destPath,
+ jintArray inkPageIndicesArray,
+ jintArray inkTypesArray,
+ jintArray inkColorsArray,
+ jfloatArray inkStrokeWidthsArray,
+ jintArray inkPointOffsetsArray,
+ jintArray inkPointCountsArray,
+ jfloatArray inkPointsArray,
+ jintArray textPageIndicesArray,
+ jfloatArray textBoundsArray,
+ jintArray textColorsArray,
+ jintArray textBackgroundColorsArray,
+ jfloatArray textFontSizesArray,
+ jintArray textFlagsArray,
+ jobjectArray textValuesArray,
+ jobjectArray textFontPathsArray,
+ jobjectArray textFontNamesArray,
+ jintArray rasterPageIndicesArray,
+ jfloatArray rasterBoundsArray,
+ jintArray rasterWidthsArray,
+ jintArray rasterHeightsArray,
+ jintArray rasterPixelOffsetsArray,
+ jintArray rasterPixelsArray,
+ jintArray highlightPageIndicesArray,
+ jintArray highlightColorsArray,
+ jintArray highlightRectOffsetsArray,
+ jintArray highlightRectCountsArray,
+ jfloatArray highlightRectsArray,
+ jobjectArray highlightContentsArray) {
+ std::lock_guard lock(g_pdfium_mutex);
+
+ if (!init_pdfium() || !validate_export_functions()) {
+ LOGE("PdfiumExport: PDFium export functions are unavailable.");
+ return JNI_FALSE;
+ }
+
+ std::string source = jstring_to_utf8(env, sourcePath);
+ std::string dest = jstring_to_utf8(env, destPath);
+ if (source.empty() || dest.empty()) {
+ LOGE("PdfiumExport: Missing source or destination path.");
+ return JNI_FALSE;
+ }
+
+ std::vector inkPageIndices = read_int_array(env, inkPageIndicesArray);
+ std::vector inkTypes = read_int_array(env, inkTypesArray);
+ std::vector inkColors = read_int_array(env, inkColorsArray);
+ std::vector inkStrokeWidths = read_float_array(env, inkStrokeWidthsArray);
+ std::vector inkPointOffsets = read_int_array(env, inkPointOffsetsArray);
+ std::vector inkPointCounts = read_int_array(env, inkPointCountsArray);
+ std::vector inkPoints = read_float_array(env, inkPointsArray);
+
+ std::vector textPageIndices = read_int_array(env, textPageIndicesArray);
+ std::vector textBounds = read_float_array(env, textBoundsArray);
+ std::vector textColors = read_int_array(env, textColorsArray);
+ std::vector textBackgroundColors = read_int_array(env, textBackgroundColorsArray);
+ std::vector textFontSizes = read_float_array(env, textFontSizesArray);
+ std::vector textFlags = read_int_array(env, textFlagsArray);
+ std::vector textFontPaths = read_string_array(env, textFontPathsArray);
+ std::vector textFontNames = read_string_array(env, textFontNamesArray);
+ if (!textPageIndices.empty() && !validate_text_object_functions()) {
+ LOGE("PdfiumExport: Text export functions are unavailable.");
+ return JNI_FALSE;
+ }
+
+ std::vector rasterPageIndices = read_int_array(env, rasterPageIndicesArray);
+ std::vector rasterBounds = read_float_array(env, rasterBoundsArray);
+ std::vector rasterWidths = read_int_array(env, rasterWidthsArray);
+ std::vector rasterHeights = read_int_array(env, rasterHeightsArray);
+ std::vector rasterPixelOffsets = read_int_array(env, rasterPixelOffsetsArray);
+ jsize rasterPixelsLength = rasterPixelsArray ? env->GetArrayLength(rasterPixelsArray) : 0;
+ if (!rasterPageIndices.empty() && !validate_raster_image_functions()) {
+ LOGE("PdfiumExport: Raster image export functions are unavailable.");
+ return JNI_FALSE;
+ }
+ if (!rasterPageIndices.empty() && rasterPixelsLength <= 0) {
+ LOGE("PdfiumExport: Raster image payload is missing pixels.");
+ return JNI_FALSE;
+ }
+
+ std::vector highlightPageIndices = read_int_array(env, highlightPageIndicesArray);
+ std::vector highlightColors = read_int_array(env, highlightColorsArray);
+ std::vector highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray);
+ std::vector highlightRectCounts = read_int_array(env, highlightRectCountsArray);
+ std::vector highlightRects = read_float_array(env, highlightRectsArray);
+
+ void* document = load_document_func(source.c_str(), nullptr);
+ if (!document) {
+ LOGE("PdfiumExport: Failed to load source PDF.");
+ return JNI_FALSE;
+ }
+
+ int pageCount = get_page_count_func(document);
+ bool hadFailure = false;
+ jint* rasterPixels = nullptr;
+ std::vector rasterBitmapsToDestroy;
+ auto releaseRasterResources = [&]() {
+ for (void* bitmap : rasterBitmapsToDestroy) {
+ if (bitmap && bitmap_destroy_func) {
+ bitmap_destroy_func(bitmap);
+ }
+ }
+ rasterBitmapsToDestroy.clear();
+ if (rasterPixels) {
+ env->ReleaseIntArrayElements(rasterPixelsArray, rasterPixels, JNI_ABORT);
+ rasterPixels = nullptr;
+ }
+ };
+
+ if (!rasterPageIndices.empty()) {
+ rasterPixels = env->GetIntArrayElements(rasterPixelsArray, nullptr);
+ if (!rasterPixels) {
+ LOGE("PdfiumExport: Unable to access raster image pixels.");
+ close_document_func(document);
+ return JNI_FALSE;
+ }
+ }
+
+ for (size_t i = 0; i < inkPageIndices.size(); i++) {
+ if (i >= inkTypes.size() || i >= inkColors.size() || i >= inkStrokeWidths.size() ||
+ i >= inkPointOffsets.size() || i >= inkPointCounts.size()) {
+ hadFailure = true;
+ break;
+ }
+
+ int pageIndex = inkPageIndices[i];
+ int pointOffset = inkPointOffsets[i];
+ int pointCount = inkPointCounts[i];
+ if (pageIndex < 0 || pageIndex >= pageCount || pointOffset < 0 || pointCount < 2 ||
+ (pointOffset + pointCount) * 2 > static_cast(inkPoints.size())) {
+ hadFailure = true;
+ continue;
+ }
+
+ void* page = load_page_func(document, pageIndex);
+ if (!page) {
+ hadFailure = true;
+ continue;
+ }
+
+ float pageWidth = get_page_width_bridge(page);
+ float pageHeight = get_page_height_bridge(page);
+ if (pageWidth <= 0.0f || pageHeight <= 0.0f) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ void* annot = create_annot_func(page, kPdfAnnotInk);
+ if (!annot) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ std::vector points;
+ points.reserve(static_cast(pointCount));
+ float minX = pageWidth;
+ float maxX = 0.0f;
+ float minY = pageHeight;
+ float maxY = 0.0f;
+
+ for (int j = 0; j < pointCount; j++) {
+ int sourceIndex = (pointOffset + j) * 2;
+ float x = clamp_unit(inkPoints[sourceIndex]) * pageWidth;
+ float y = (1.0f - clamp_unit(inkPoints[sourceIndex + 1])) * pageHeight;
+ points.push_back(FS_POINTF_BRIDGE{x, y});
+ minX = std::min(minX, x);
+ maxX = std::max(maxX, x);
+ minY = std::min(minY, y);
+ maxY = std::max(maxY, y);
+ }
+
+ float strokeWidth = std::max(0.25f, inkStrokeWidths[i] * pageWidth);
+ FS_RECTF_BRIDGE rect = make_pdf_rect(minX, maxY, maxX, minY, strokeWidth * 1.5f);
+ set_annot_rect_func(annot, &rect);
+
+ unsigned int r, g, b, a;
+ argb_to_rgba(inkColors[i], &r, &g, &b, &a);
+ if ((inkTypes[i] == 1 || inkTypes[i] == 2) && a == 255) {
+ a = 102;
+ }
+ set_annot_color_func(annot, kAnnotColor, r, g, b, a);
+ set_annot_border_func(annot, 0.0f, 0.0f, strokeWidth);
+ if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint);
+
+ if (add_ink_stroke_func(annot, points.data(), points.size()) < 0) {
+ hadFailure = true;
+ }
+
+ set_annot_string_from_ascii(annot, "Contents", "Ink");
+ if (generate_content_func) generate_content_func(page);
+ close_annot_func(annot);
+ close_page_func(page);
+ }
+
+ for (size_t i = 0; i < highlightPageIndices.size(); i++) {
+ if (i >= highlightColors.size() || i >= highlightRectOffsets.size() || i >= highlightRectCounts.size()) {
+ hadFailure = true;
+ break;
+ }
+
+ int pageIndex = highlightPageIndices[i];
+ int rectOffset = highlightRectOffsets[i];
+ int rectCount = highlightRectCounts[i];
+ if (pageIndex < 0 || pageIndex >= pageCount || rectOffset < 0 || rectCount <= 0 ||
+ (rectOffset + rectCount) * 4 > static_cast(highlightRects.size())) {
+ hadFailure = true;
+ continue;
+ }
+
+ std::vector quads;
+ quads.reserve(static_cast(rectCount));
+ float unionLeft = 0.0f;
+ float unionRight = 0.0f;
+ float unionTop = 0.0f;
+ float unionBottom = 0.0f;
+
+ for (int j = 0; j < rectCount; j++) {
+ int sourceIndex = (rectOffset + j) * 4;
+ float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
+ float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
+ float top = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
+ float bottom = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
+ if (right <= left || top <= bottom) continue;
+
+ quads.push_back(FS_QUADPOINTSF_BRIDGE{left, top, right, top, left, bottom, right, bottom});
+ if (quads.size() == 1) {
+ unionLeft = left;
+ unionRight = right;
+ unionTop = top;
+ unionBottom = bottom;
+ } else {
+ unionLeft = std::min(unionLeft, left);
+ unionRight = std::max(unionRight, right);
+ unionTop = std::max(unionTop, top);
+ unionBottom = std::min(unionBottom, bottom);
+ }
+ }
+
+ if (quads.empty()) {
+ hadFailure = true;
+ continue;
+ }
+
+ void* page = load_page_func(document, pageIndex);
+ if (!page) {
+ hadFailure = true;
+ continue;
+ }
+
+ void* annot = create_annot_func(page, kPdfAnnotHighlight);
+ if (!annot) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ for (const FS_QUADPOINTSF_BRIDGE& quad : quads) {
+ if (!append_attachment_points_func(annot, &quad)) {
+ hadFailure = true;
+ }
+ }
+ FS_RECTF_BRIDGE rect = make_pdf_rect(unionLeft, unionTop, unionRight, unionBottom, 1.0f);
+ set_annot_rect_func(annot, &rect);
+
+ unsigned int r, g, b, a;
+ argb_to_rgba(highlightColors[i], &r, &g, &b, &a);
+ if (a == 255) a = 102;
+ set_annot_color_func(annot, kAnnotColor, r, g, b, a);
+ if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint);
+
+ if (highlightContentsArray && i < static_cast(env->GetArrayLength(highlightContentsArray))) {
+ auto content = static_cast(env->GetObjectArrayElement(highlightContentsArray, static_cast(i)));
+ if (content) {
+ set_annot_string_from_jstring(env, annot, "Contents", content);
+ env->DeleteLocalRef(content);
+ }
+ }
+
+ if (generate_content_func) generate_content_func(page);
+ close_annot_func(annot);
+ close_page_func(page);
+ }
+
+ for (size_t i = 0; i < rasterPageIndices.size(); i++) {
+ if (i >= rasterWidths.size() || i >= rasterHeights.size() || i >= rasterPixelOffsets.size() ||
+ (i + 1) * 4 > rasterBounds.size()) {
+ hadFailure = true;
+ break;
+ }
+
+ int pageIndex = rasterPageIndices[i];
+ int imageWidth = rasterWidths[i];
+ int imageHeight = rasterHeights[i];
+ int pixelOffset = rasterPixelOffsets[i];
+ long long pixelCount = static_cast(imageWidth) * static_cast(imageHeight);
+ if (pageIndex < 0 || pageIndex >= pageCount || imageWidth <= 0 || imageHeight <= 0 ||
+ pixelOffset < 0 || pixelCount <= 0 ||
+ static_cast(pixelOffset) + pixelCount > static_cast(rasterPixelsLength)) {
+ LOGE("PdfiumExport: Invalid raster image payload index=%zu page=%d size=%dx%d offset=%d pixels=%d",
+ i, pageIndex, imageWidth, imageHeight, pixelOffset, rasterPixelsLength);
+ hadFailure = true;
+ continue;
+ }
+
+ void* page = load_page_func(document, pageIndex);
+ if (!page) {
+ hadFailure = true;
+ continue;
+ }
+
+ float pageWidth = get_page_width_bridge(page);
+ float pageHeight = get_page_height_bridge(page);
+ if (pageWidth <= 0.0f || pageHeight <= 0.0f) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ float left = clamp_unit(rasterBounds[i * 4]) * pageWidth;
+ float top = (1.0f - clamp_unit(rasterBounds[i * 4 + 1])) * pageHeight;
+ float right = clamp_unit(rasterBounds[i * 4 + 2]) * pageWidth;
+ float bottom = (1.0f - clamp_unit(rasterBounds[i * 4 + 3])) * pageHeight;
+ FS_RECTF_BRIDGE rect = make_pdf_rect(left, top, right, bottom, 0.0f);
+ float rectWidth = rect.right - rect.left;
+ float rectHeight = rect.top - rect.bottom;
+ if (rectWidth <= 0.5f || rectHeight <= 0.5f) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ void* imageObject = new_image_object_func(document);
+ if (!imageObject) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ void* bitmap = bitmap_create_ex_func(
+ imageWidth,
+ imageHeight,
+ kPdfBitmapBgra,
+ reinterpret_cast(rasterPixels + pixelOffset),
+ imageWidth * 4
+ );
+ if (!bitmap) {
+ if (destroy_page_object_func) destroy_page_object_func(imageObject);
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ void* pages[] = {page};
+ if (!set_image_bitmap_func(pages, 1, imageObject, bitmap)) {
+ bitmap_destroy_func(bitmap);
+ if (destroy_page_object_func) destroy_page_object_func(imageObject);
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ bool positioned = true;
+ if (set_image_matrix_func) {
+ positioned = set_image_matrix_func(imageObject, rectWidth, 0.0, 0.0, rectHeight, rect.left, rect.bottom) != 0;
+ } else {
+ transform_page_object_func(imageObject, rectWidth, 0.0, 0.0, rectHeight, rect.left, rect.bottom);
+ }
+ if (!positioned) {
+ bitmap_destroy_func(bitmap);
+ if (destroy_page_object_func) destroy_page_object_func(imageObject);
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ if (!insert_page_object_or_destroy(page, imageObject)) {
+ bitmap_destroy_func(bitmap);
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ rasterBitmapsToDestroy.push_back(bitmap);
+ if (!generate_content_func(page)) {
+ hadFailure = true;
+ }
+ close_page_func(page);
+ }
+
+ for (size_t i = 0; i < textPageIndices.size(); i++) {
+ if (i >= textColors.size() || i >= textBackgroundColors.size() || i >= textFontSizes.size() ||
+ i >= textFlags.size() || i >= textFontPaths.size() || i >= textFontNames.size() ||
+ (i + 1) * 4 > textBounds.size()) {
+ hadFailure = true;
+ break;
+ }
+
+ int pageIndex = textPageIndices[i];
+ if (pageIndex < 0 || pageIndex >= pageCount) {
+ hadFailure = true;
+ continue;
+ }
+
+ void* page = load_page_func(document, pageIndex);
+ if (!page) {
+ hadFailure = true;
+ continue;
+ }
+
+ float pageWidth = get_page_width_bridge(page);
+ float pageHeight = get_page_height_bridge(page);
+ if (pageWidth <= 0.0f || pageHeight <= 0.0f) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+
+ float left = clamp_unit(textBounds[i * 4]) * pageWidth;
+ float top = (1.0f - clamp_unit(textBounds[i * 4 + 1])) * pageHeight;
+ float right = clamp_unit(textBounds[i * 4 + 2]) * pageWidth;
+ float bottom = (1.0f - clamp_unit(textBounds[i * 4 + 3])) * pageHeight;
+ FS_RECTF_BRIDGE rect = make_pdf_rect(left, top, right, bottom, 0.0f);
+ if (rect.right - rect.left <= 1.0f || rect.top - rect.bottom <= 1.0f) {
+ close_page_func(page);
+ hadFailure = true;
+ continue;
+ }
+ float rectWidth = std::max(1.0f, rect.right - rect.left);
+ bool preserveLines = (textFlags[i] & kTextFlagAbsoluteLine) != 0;
+
+ unsigned int textR, textG, textB, textA;
+ argb_to_rgba(textColors[i], &textR, &textG, &textB, &textA);
+ unsigned int bgR, bgG, bgB, bgA;
+ argb_to_rgba(textBackgroundColors[i], &bgR, &bgG, &bgB, &bgA);
+
+ float fontSize = textFontSizes[i] > 1.0f ? textFontSizes[i] : textFontSizes[i] * pageHeight;
+ if (fontSize <= 0.0f) fontSize = 12.0f;
+
+ if (textValuesArray && i < static_cast(env->GetArrayLength(textValuesArray))) {
+ auto content = static_cast(env->GetObjectArrayElement(textValuesArray, static_cast(i)));
+ if (content) {
+ auto lines = wrap_wide_lines(
+ split_jstring_lines_wide(env, content),
+ preserveLines ? rectWidth : std::max(1.0f, rectWidth - 4.0f),
+ fontSize,
+ preserveLines
+ );
+ float lineHeight = std::max(fontSize * 1.18f, fontSize + 2.0f);
+ float baseline = preserveLines ? top : rect.top - (fontSize * 0.85f);
+ float textX = preserveLines ? rect.left : rect.left + 2.0f;
+ bool insertedAnyText = false;
+ float decorationStroke = std::max(0.35f, fontSize * 0.035f);
+
+ for (const auto& line : lines) {
+ if (!preserveLines && baseline < rect.bottom + 1.0f) break;
+ float lineVisualWidth = line.size() > 1
+ ? std::min(
+ std::max(1.0f, rectWidth),
+ std::max(1.0f, static_cast(line.size() - 1) * fontSize * 0.55f))
+ : 0.0f;
+ if (preserveLines) {
+ lineVisualWidth = std::max(1.0f, rectWidth);
+ }
+ if (line.size() > 1 && bgA > 0) {
+ insert_background_rect_object(
+ page,
+ textX,
+ baseline - (fontSize * 0.95f),
+ lineVisualWidth + (fontSize * 0.2f),
+ fontSize * 1.2f,
+ bgR,
+ bgG,
+ bgB,
+ bgA
+ );
+ }
+ if (insert_text_line_object(
+ document,
+ page,
+ line,
+ textX,
+ baseline,
+ fontSize,
+ textR,
+ textG,
+ textB,
+ textA,
+ textFlags[i],
+ textFontPaths[i],
+ textFontNames[i])) {
+ if (line.size() > 1) insertedAnyText = true;
+ }
+
+ if (line.size() > 1 && (textFlags[i] & (kTextFlagUnderline | kTextFlagStrikeThrough))) {
+ if (textFlags[i] & kTextFlagUnderline) {
+ insert_decoration_line_object(
+ page,
+ textX,
+ baseline - 2.0f,
+ textX + lineVisualWidth,
+ textR,
+ textG,
+ textB,
+ textA,
+ decorationStroke
+ );
+ }
+ if (textFlags[i] & kTextFlagStrikeThrough) {
+ insert_decoration_line_object(
+ page,
+ textX,
+ baseline + fontSize * 0.35f,
+ textX + lineVisualWidth,
+ textR,
+ textG,
+ textB,
+ textA,
+ decorationStroke
+ );
+ }
+ }
+
+ baseline -= lineHeight;
+ }
+
+ if (!insertedAnyText) {
+ LOGE("PdfiumExport: No text inserted for text item index=%zu page=%d fontPath=%s fontName=%s textChars=%d rect=(%.2f,%.2f,%.2f,%.2f)",
+ i,
+ pageIndex,
+ textFontPaths[i].c_str(),
+ textFontNames[i].c_str(),
+ content ? env->GetStringLength(content) : 0,
+ rect.left,
+ rect.top,
+ rect.right,
+ rect.bottom);
+ hadFailure = true;
+ }
+ env->DeleteLocalRef(content);
+ }
+ } else {
+ hadFailure = true;
+ }
+
+ if (generate_content_func) generate_content_func(page);
+ close_page_func(page);
+ }
+
+ FILE* output = fopen(dest.c_str(), "wb");
+ if (!output) {
+ LOGE("PdfiumExport: Failed to open destination PDF.");
+ releaseRasterResources();
+ close_document_func(document);
+ return JNI_FALSE;
+ }
+
+ PdfiumFileWriter writer{{1, write_pdf_block}, output};
+ int saved = save_as_copy_func(document, &writer.base, kPdfNoIncremental);
+ fclose(output);
+ close_document_func(document);
+ releaseRasterResources();
+
+ if (!saved) {
+ LOGE("PdfiumExport: Save result=%d hadFailure=%d", saved, hadFailure ? 1 : 0);
+ remove(dest.c_str());
+ return JNI_FALSE;
+ }
+
+ if (hadFailure) {
+ LOGE("PdfiumExport: Saved PDF with partial annotation/text failures.");
+ }
+
+ return JNI_TRUE;
+}
+
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
std::lock_guard lock(g_pdfium_mutex);
diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
index ceca8b9..272691f 100644
--- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
+++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
@@ -17,6 +17,26 @@ private val codeOrDataExtensions = setOf(
"go"
)
+private val manualOnlyReaderMimeTypes = setOf(
+ "text/csv",
+ "text/comma-separated-values",
+ "text/tab-separated-values",
+ "application/json",
+ "application/xml",
+ "text/xml",
+ "text/x-java-source",
+ "text/x-python",
+ "text/x-kotlin",
+ "text/javascript",
+ "application/javascript",
+ "text/x-c",
+ "text/x-c++",
+ "text/x-csharp",
+ "text/x-ruby",
+ "text/x-go",
+ "text/x-log"
+)
+
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
@@ -44,6 +64,22 @@ internal fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
+internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
+ val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
+ return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
+}
+
+internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
+ val normalized = mimeType?.lowercase() ?: return false
+ return normalized in manualOnlyReaderMimeTypes
+}
+
+internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
+ if (isManualOnlyReaderFileName(name)) return false
+ if (resolveFileTypeFromName(name) != null) return true
+ return !isManualOnlyReaderMimeType(mimeType)
+}
+
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
index 3cd554b..554c0c6 100644
--- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
+++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
@@ -40,7 +40,6 @@ import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata
import java.io.File
import android.provider.DocumentsContract
-import java.security.MessageDigest
class FolderSyncWorker(
private val appContext: Context,
@@ -316,7 +315,13 @@ class FolderSyncWorker(
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
val type = getFileType(name, mimeType)
- if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
+ if (
+ type != null &&
+ type in allowedFileTypes &&
+ isLocalFolderSyncEligibleFile(name, mimeType) &&
+ !name.endsWith(".json") &&
+ !name.startsWith(".")
+ ) {
supportedBooksSeen++
val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId)
@@ -554,11 +559,14 @@ class FolderSyncWorker(
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
+ val safeSlashBookId = book.bookId.replace("/", "_")
+ val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
val localFiles = listOf(
- File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
- File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
- File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
- File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
+ File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
+ File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
+ File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
+ File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
+ File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
@@ -607,10 +615,7 @@ class FolderSyncWorker(
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
val relativePath = buildRelativePath(rootDocId, docId, name)
- if (relativePath.equals(name, ignoreCase = true)) {
- return "local_$name"
- }
- return "local_${name}_${shortHash(relativePath.lowercase())}"
+ return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
@@ -625,11 +630,6 @@ class FolderSyncWorker(
return relative.ifBlank { fallbackName }
}
- private fun shortHash(value: String): String {
- val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
- return bytes.joinToString("") { "%02x".format(it) }.take(12)
- }
-
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
val uriString = item.uriString ?: return null
return try {
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
index 619b37e..0b619e2 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
@@ -3147,11 +3147,12 @@ fun OpdsBookDetailsSheet(
}
}
- if (!entry.summary.isNullOrBlank()) {
+ val summary = entry.summary
+ if (!summary.isNullOrBlank()) {
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- val cleanSummary = remember(entry.summary) {
- val preProcessed = entry.summary
+ val cleanSummary = remember(summary) {
+ val preProcessed = summary
.replace("
", "\n")
.replace("
", "\n\n")
Jsoup.parse(preProcessed).text().trim()
diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
index 3277879..323fb80 100644
--- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
@@ -4,8 +4,8 @@ import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
-import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
+import com.aryan.reader.shared.SmartCollectionEngine
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List
@@ -183,7 +183,7 @@ class LibraryStateProjector(
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
- val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
+ val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt
index 3703770..d342f53 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt
@@ -53,6 +53,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
+import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.CloudflareRepository
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.data.FeedbackRepository
@@ -83,8 +84,8 @@ import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.pdf.PdfCoverGenerator
-import com.aryan.reader.pdf.PdfExporter
import com.aryan.reader.pdf.PdfUserHighlight
+import com.aryan.reader.pdf.PdfiumAnnotationExporter
import com.aryan.reader.pdf.ReflowWorker
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@@ -94,7 +95,9 @@ import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage
-import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
+import com.aryan.reader.shared.SharedLibraryEditor
+import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
+import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -709,27 +712,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createAndAssignTag(name: String, bookIds: Set) {
- val trimmedName = name.trim()
- if (trimmedName.isBlank() || bookIds.isEmpty()) return
+ val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
+ if (sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
val tagId = UUID.randomUUID().toString()
val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE)
val color = colors.random().toInt()
-
- val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis())
+ val now = System.currentTimeMillis()
+ val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch
recentFilesRepository.createTag(tag)
- bookIds.forEach { bookId ->
+ sanitizedBookIds.forEach { bookId ->
recentFilesRepository.assignTagToBook(bookId, tagId)
}
}
}
fun toggleTagForBooks(tagId: String, bookIds: Set, assign: Boolean) {
- if (tagId.isBlank() || bookIds.isEmpty()) return
+ val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
+ if (tagId.isBlank() || sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
- bookIds.forEach { bookId ->
+ sanitizedBookIds.forEach { bookId ->
if (assign) {
recentFilesRepository.assignTagToBook(bookId, tagId)
} else {
@@ -1094,9 +1098,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) }
}
}
- viewModelScope.launch(Dispatchers.IO) {
- PDFBoxResourceLoader.init(getApplication())
- }
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
@@ -1779,7 +1780,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId)
val outputStream = appContext.contentResolver.openOutputStream(destUri)
if (outputStream != null) {
- PdfExporter.exportAnnotatedPdf(
+ PdfiumAnnotationExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
@@ -1889,24 +1890,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val destFile = File(shareDir, filename)
- val outputStream = FileOutputStream(destFile)
+ FileOutputStream(destFile).use { outputStream ->
+ if (includeAnnotations) {
+ val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
- if (includeAnnotations) {
- val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
-
- PdfExporter.exportAnnotatedPdf(
- context = appContext,
- sourceUri = sourceUri,
- destStream = outputStream,
- virtualPages = virtualPages,
- inkAnnotations = annotations,
- richTextPageLayouts = richTextPageLayouts,
- textBoxes = textBoxes,
- highlights = highlights
- )
- } else {
- appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
- input.copyTo(outputStream)
+ PdfiumAnnotationExporter.exportAnnotatedPdf(
+ context = appContext,
+ sourceUri = sourceUri,
+ destStream = outputStream,
+ virtualPages = virtualPages,
+ inkAnnotations = annotations,
+ richTextPageLayouts = richTextPageLayouts,
+ textBoxes = textBoxes,
+ highlights = highlights
+ )
+ } else {
+ appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
+ input.copyTo(outputStream)
+ }
}
}
@@ -1953,6 +1954,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
+
+ if (book.isManualOnlyReaderFile()) {
+ Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
+ return
+ }
val currentUser = uiState.value.currentUser ?: return
viewModelScope.launch {
@@ -1972,6 +1978,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val hasTextBoxes = textBoxFile.exists()
val hasHighlights = highlightFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
+ "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
+ )
if (hasAnyData) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
@@ -1985,12 +1995,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (file == null || !file.exists()) return
try {
val content = file.readText().trim()
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " +
+ "file=${file.absolutePath}"
+ )
+ }
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ .e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}")
+ }
Timber.e(e, "Failed to parse local $key file")
}
}
@@ -2003,7 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
- bundleFile.writeText(bundleJson.toString())
+ val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
+ bundleFile.writeText(canonicalBundle)
+ if (hasRichText) {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
+ "bundleFile=${bundleFile.absolutePath}"
+ )
+ }
val uploaded = googleDriveRepository.uploadAnnotationFile(
accessToken, book.bookId, bundleFile
@@ -2011,9 +2038,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bundleFile.delete()
if (uploaded != null) {
+ if (hasRichText) {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ .d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
+ }
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
+ if (hasRichText) {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ .e("android.cloud.export.uploadFailed book=${book.bookId}")
+ }
Timber.tag("AnnotationSync")
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
@@ -2204,7 +2239,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
while (nextIdx < totalChapters) {
Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.")
- val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx)
+ val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx, bookId)
if (!nativeChunks.isNullOrEmpty()) {
val token = getAuthToken()
@@ -2231,7 +2266,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
// Save reading position locally
val cfi = nativeChunks.firstOrNull()?.sourceCfi
if (cfi != null) {
- val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi)
+ val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId)
if (locator != null) {
recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString ->
recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f)
@@ -2872,25 +2907,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else {
allFiles.filter { it.sourceFolderUri == null }
}
- filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
+ filtered
+ .filterNot { it.uriString?.startsWith("opds-pse") == true }
+ .filterNot { it.isManualOnlyReaderFile() }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
+ val remoteBooks = remoteBooksDeferred.await()
+ .filterNot { it.isManualOnlyReaderFile() }
+ val remoteShelves = remoteShelvesDeferred.await()
+ val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet()
val allKnownShelfNames =
- (localShelfNames + remoteShelvesDeferred.await().map { it.name }).toSet()
+ (localShelfNames + remoteShelves.map { it.name }).toSet()
val localShelves = allKnownShelfNames.mapNotNull { name ->
val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L)
if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null
val bookIds = prefs.getStringSet(
"$KEY_SHELF_CONTENT_PREFIX$name", emptySet()
- ).orEmpty().toList()
+ ).orEmpty().filter { it in syncableBookIds }
val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false)
ShelfMetadata(name, bookIds, timestamp, isDeleted)
}
- val remoteBooks = remoteBooksDeferred.await()
- val remoteShelves = remoteShelvesDeferred.await()
-
// 3. Merge Books
val localBooksMap = localBooks.associateBy { it.bookId }
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
@@ -2985,7 +3023,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
- remote.bookIds.toSet()
+ remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@@ -3014,7 +3052,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
- remote.bookIds.toSet()
+ remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@@ -3033,7 +3071,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalMergedBooks = withContext(Dispatchers.IO) {
recentFilesRepository.getAllFilesForSync()
- }
+ }.filterNot { it.isManualOnlyReaderFile() }
val remoteFiles = withContext(Dispatchers.IO) {
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
}
@@ -3100,11 +3138,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try {
val jsonString = tempDownloadFile.readText()
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
+ )
// Determine format
val isBundle = try {
val obj = JSONObject(jsonString)
- obj.has("version") || obj.has("ink") || obj.has("text") || obj.has("layout")
+ obj.has("version") ||
+ obj.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
+ obj.has("ink") ||
+ obj.has("text") ||
+ obj.has("layout") ||
+ obj.has("textBoxes") ||
+ obj.has("highlights")
} catch (_: Exception) {
false
}
@@ -3124,13 +3171,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
highlightFile.parentFile?.mkdirs()
if (isBundle) {
- val bundle = JSONObject(jsonString)
+ val bundle = JSONObject(
+ SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
+ )
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
+ )
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
- file.writeText(bundle.get(key).toString())
+ val content = bundle.get(key).toString()
+ file.writeText(content)
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
+ )
+ }
} else {
+ if (key == "text" && file.exists()) {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
+ )
+ }
if (file.exists()) file.delete()
}
}
@@ -4592,21 +4655,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createShelf(name: String) {
- if (name.isNotBlank()) {
- viewModelScope.launch {
- val shelfId = UUID.randomUUID().toString()
- val shelf = com.aryan.reader.data.ShelfEntity(
- id = shelfId,
- name = name,
- isSmart = false,
- smartRulesJson = null,
- createdAt = System.currentTimeMillis(),
- updatedAt = System.currentTimeMillis()
- )
- recentFilesRepository.addShelf(shelf)
- dismissCreateShelfDialog()
- syncShelfChangeToFirestore(shelfId)
- }
+ val shelfId = UUID.randomUUID().toString()
+ val now = System.currentTimeMillis()
+ val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return
+ viewModelScope.launch {
+ recentFilesRepository.addShelf(shelf)
+ dismissCreateShelfDialog()
+ syncShelfChangeToFirestore(shelfId)
}
}
@@ -4651,12 +4706,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun renameShelf(shelfId: String, newName: String) {
- if (shelfId.isBlank() || newName.isBlank()) {
+ val cleanName = SharedLibraryEditor.cleanShelfName(newName)
+ if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) {
dismissRenameShelfDialog()
return
}
viewModelScope.launch {
- recentFilesRepository.renameShelf(shelfId, newName)
+ recentFilesRepository.renameShelf(shelfId, cleanName)
syncShelfChangeToFirestore(shelfId)
_internalState.update { it.copy(viewingShelfId = shelfId) }
persistLibraryLandingState()
@@ -4665,7 +4721,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun deleteShelf(shelfId: String) {
- if (shelfId.isBlank() || shelfId == "unshelved") {
+ if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
dismissDeleteShelfDialog()
return
}
@@ -4697,21 +4753,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun removeContextualItemsFromShelf() {
val shelfId = _internalState.value.viewingShelfId
- if (shelfId.isNullOrBlank() || shelfId == "unshelved") {
+ if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
clearContextualAction()
return
}
+ val targetShelfId = shelfId ?: return
- val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId }
+ val bookIdsToRemove = SharedLibraryEditor.cleanBookIds(_internalState.value.contextualActionItems.map { it.bookId })
if (bookIdsToRemove.isEmpty()) {
clearContextualAction()
return
}
viewModelScope.launch {
- recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove)
+ recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList())
clearContextualAction()
- syncShelfChangeToFirestore(shelfId)
+ syncShelfChangeToFirestore(targetShelfId)
}
}
@@ -4755,6 +4812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteSelectedShelves() {
val shelvesToDelete = _internalState.value.contextualActionShelfIds
+ .filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) }
if (shelvesToDelete.isEmpty()) {
clearShelfContextualAction()
return
@@ -4788,7 +4846,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
+ val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync()
+ .filter { it.isManualOnlyReaderFile() }
+ .mapTo(mutableSetOf()) { it.bookId }
val bookIds = crossRefs.map { it.bookId }
+ .filterNot { it in manualOnlyBookIds }
val shelfMetadata = ShelfMetadata(
name = shelf.name,
@@ -4814,8 +4876,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun addBooksToShelf(shelfId: String) {
- val bookIdsToAdd = _internalState.value.booksSelectedForAdding
- if (bookIdsToAdd.isEmpty()) {
+ val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding)
+ if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) {
dismissAddBooksToShelf()
return
}
@@ -4943,6 +5005,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.associateBy { it.name }
for (item in managedBooks) {
+ if (item.isManualOnlyReaderFile()) {
+ cleanupBookDataLocally(item.bookId)
+ recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
+ continue
+ }
+
recentFilesRepository.markAsDeleted(listOf(item.bookId))
cleanupBookDataLocally(item.bookId)
@@ -5462,3 +5530,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
}
+
+private fun RecentFileItem.isManualOnlyReaderFile(): Boolean {
+ return isManualOnlyReaderFileName(displayName)
+}
+
+private fun BookMetadata.isManualOnlyReaderFile(): Boolean {
+ return isManualOnlyReaderFileName(displayName)
+}
diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
index c397738..4ed229f 100644
--- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
+++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
@@ -12,6 +12,7 @@ import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
+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.ReadStatusFilter as SharedReadStatusFilter
@@ -30,15 +31,18 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
type = type.toSharedFileType(),
displayName = customName ?: displayName,
timestamp = timestamp,
+ coverImagePath = coverImagePath,
title = title,
author = author,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
sourceFolder = sourceFolderUri,
+ folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
- tags = tags.map { it.toSharedTag() }
+ tags = tags.map { it.toSharedTag() },
+ readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
@@ -50,6 +54,15 @@ fun TagEntity.toSharedTag(): SharedTag {
)
}
+fun SharedTag.toTagEntity(createdAt: Long): TagEntity {
+ return TagEntity(
+ id = id,
+ name = name,
+ color = color,
+ createdAt = createdAt
+ )
+}
+
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
return ShelfRecord(
id = id,
@@ -59,6 +72,17 @@ fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
)
}
+fun ShelfRecord.toShelfEntity(createdAt: Long, updatedAt: Long = createdAt): ShelfEntity {
+ return ShelfEntity(
+ id = id,
+ name = name,
+ isSmart = isSmart,
+ smartRulesJson = smartRulesJson,
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ )
+}
+
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
return SharedBookShelfRef(
bookId = bookId,
diff --git a/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt b/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
new file mode 100644
index 0000000..5167ba5
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
@@ -0,0 +1,43 @@
+package com.aryan.reader
+
+import android.content.Context
+import androidx.core.content.edit
+import com.aryan.reader.paginatedreader.TtsChunk
+import com.aryan.reader.shared.ReaderTtsReplacementEngine
+import com.aryan.reader.shared.ReaderTtsReplacementPreferences
+import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
+
+private const val READER_PREFS_NAME = "reader_prefs"
+private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
+
+fun loadTtsReplacementPreferences(context: Context): ReaderTtsReplacementPreferences {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ return ReaderTtsReplacementPreferencesJson.decodeOrEmpty(prefs.getString(TTS_REPLACEMENTS_KEY, null))
+}
+
+fun saveTtsReplacementPreferences(
+ context: Context,
+ preferences: ReaderTtsReplacementPreferences,
+) {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit {
+ putString(TTS_REPLACEMENTS_KEY, ReaderTtsReplacementPreferencesJson.encode(preferences))
+ }
+}
+
+fun TtsChunk.withTtsReplacements(
+ preferences: ReaderTtsReplacementPreferences,
+ bookId: String?,
+): TtsChunk {
+ val spoken = ReaderTtsReplacementEngine.apply(
+ text = text,
+ preferences = preferences,
+ bookId = bookId,
+ ).text
+ return copy(spokenText = spoken.ifBlank { text })
+}
+
+fun List.withTtsReplacements(
+ preferences: ReaderTtsReplacementPreferences,
+ bookId: String?,
+): List = map { it.withTtsReplacements(preferences, bookId) }
diff --git a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
new file mode 100644
index 0000000..e415335
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
@@ -0,0 +1,667 @@
+package com.aryan.reader
+
+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.lazy.items
+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.AssistChip
+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.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.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+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.mutableIntStateOf
+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.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.ReaderTtsReplacementBookSettings
+import com.aryan.reader.shared.ReaderTtsReplacementEngine
+import com.aryan.reader.shared.ReaderTtsReplacementPreferences
+import com.aryan.reader.shared.ReaderTtsReplacementRule
+import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
+
+private enum class TtsReplacementScope {
+ Global,
+ Book
+}
+
+private data class RuleEditTarget(
+ val scope: TtsReplacementScope,
+ val ruleId: String? = null,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TtsWordReplacementsSheet(
+ isVisible: Boolean,
+ bookId: String,
+ bookTitle: String?,
+ preferences: ReaderTtsReplacementPreferences,
+ onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ if (!isVisible) return
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var selectedTab by remember { mutableIntStateOf(0) }
+ var editTarget by remember { mutableStateOf(null) }
+
+ 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 = "TTS Word Replacements",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = "Close")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ TabRow(selectedTabIndex = selectedTab) {
+ Tab(
+ selected = selectedTab == 0,
+ onClick = {
+ selectedTab = 0
+ editTarget = null
+ },
+ text = { Text("Global") },
+ )
+ Tab(
+ selected = selectedTab == 1,
+ onClick = {
+ selectedTab = 1
+ editTarget = null
+ },
+ text = { Text("This book") },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ when (selectedTab) {
+ 0 -> GlobalReplacementTab(
+ preferences = preferences,
+ editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Global },
+ onEditTargetChange = { editTarget = it },
+ onPreferencesChange = onPreferencesChange,
+ )
+ else -> BookReplacementTab(
+ bookId = bookId,
+ preferences = preferences,
+ editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Book },
+ onEditTargetChange = { editTarget = it },
+ onPreferencesChange = onPreferencesChange,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+@Composable
+private fun GlobalReplacementTab(
+ preferences: ReaderTtsReplacementPreferences,
+ editTarget: RuleEditTarget?,
+ onEditTargetChange: (RuleEditTarget?) -> Unit,
+ onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
+) {
+ val editingRule = editTarget?.ruleId?.let { id -> preferences.globalRules.firstOrNull { it.id == id } }
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 560.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ ListItem(
+ headlineContent = { Text("Enable replacements") },
+ supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") },
+ trailingContent = {
+ Switch(
+ checked = preferences.isEnabled,
+ onCheckedChange = { onPreferencesChange(preferences.copy(isEnabled = it)) },
+ )
+ },
+ )
+ }
+ item {
+ SuggestionChips(
+ onSuggestionClick = { suggestion ->
+ onPreferencesChange(
+ preferences.copy(
+ globalRules = preferences.globalRules + suggestion.asEditableRule("global"),
+ ),
+ )
+ },
+ )
+ }
+ item {
+ TextButton(
+ onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global)) },
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add rule")
+ }
+ }
+ if (editTarget != null) {
+ item {
+ RuleEditorCard(
+ seedRule = editingRule,
+ onCancel = { onEditTargetChange(null) },
+ onSave = { rule ->
+ val updatedRules = if (editingRule == null) {
+ preferences.globalRules + rule
+ } else {
+ preferences.globalRules.map { if (it.id == editingRule.id) rule else it }
+ }
+ onPreferencesChange(preferences.copy(globalRules = updatedRules))
+ onEditTargetChange(null)
+ },
+ )
+ }
+ }
+ item {
+ ReplacementRuleList(
+ rules = preferences.globalRules,
+ emptyText = "No global replacement rules yet.",
+ onToggle = { rule, enabled ->
+ onPreferencesChange(
+ preferences.copy(
+ globalRules = preferences.globalRules.map {
+ if (it.id == rule.id) it.copy(enabled = enabled) else it
+ },
+ ),
+ )
+ },
+ onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global, it.id)) },
+ onDelete = { rule ->
+ onPreferencesChange(
+ preferences.copy(globalRules = preferences.globalRules.filterNot { it.id == rule.id }),
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun BookReplacementTab(
+ bookId: String,
+ preferences: ReaderTtsReplacementPreferences,
+ editTarget: RuleEditTarget?,
+ onEditTargetChange: (RuleEditTarget?) -> Unit,
+ onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
+) {
+ val settings = preferences.settingsForBook(bookId)
+ val localRules = preferences.rulesForBook(bookId)
+ val editingRule = editTarget?.ruleId?.let { id -> localRules.firstOrNull { it.id == id } }
+
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 560.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ BookSettingsSwitches(
+ settings = settings,
+ onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
+ )
+ }
+ item {
+ InheritedGlobalRules(
+ globalRules = preferences.globalRules,
+ settings = settings,
+ onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
+ )
+ }
+ item {
+ SuggestionChips(
+ onSuggestionClick = { suggestion ->
+ onPreferencesChange(
+ preferences.withBookRules(
+ bookId,
+ localRules + suggestion.asEditableRule("book"),
+ ),
+ )
+ },
+ )
+ }
+ item {
+ TextButton(
+ onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book)) },
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add book rule")
+ }
+ }
+ if (editTarget != null) {
+ item {
+ RuleEditorCard(
+ seedRule = editingRule,
+ onCancel = { onEditTargetChange(null) },
+ onSave = { rule ->
+ val updatedRules = if (editingRule == null) {
+ localRules + rule
+ } else {
+ localRules.map { if (it.id == editingRule.id) rule else it }
+ }
+ onPreferencesChange(preferences.withBookRules(bookId, updatedRules))
+ onEditTargetChange(null)
+ },
+ )
+ }
+ }
+ item {
+ ReplacementRuleList(
+ rules = localRules,
+ emptyText = "No book-specific rules yet.",
+ onToggle = { rule, enabled ->
+ onPreferencesChange(
+ preferences.withBookRules(
+ bookId,
+ localRules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
+ ),
+ )
+ },
+ onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book, it.id)) },
+ onDelete = { rule ->
+ onPreferencesChange(preferences.withBookRules(bookId, localRules.filterNot { it.id == rule.id }))
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun BookSettingsSwitches(
+ settings: ReaderTtsReplacementBookSettings,
+ onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
+) {
+ Card(
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ ListItem(
+ headlineContent = { Text("Use global rules here") },
+ supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") },
+ trailingContent = {
+ Switch(
+ checked = settings.globalRulesEnabled,
+ onCheckedChange = { onSettingsChange(settings.copy(globalRulesEnabled = it)) },
+ )
+ },
+ )
+ HorizontalDivider()
+ ListItem(
+ headlineContent = { Text("Enable book rules") },
+ supportingContent = { Text("Local rules run after global rules.") },
+ trailingContent = {
+ Switch(
+ checked = settings.localRulesEnabled,
+ onCheckedChange = { onSettingsChange(settings.copy(localRulesEnabled = it)) },
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun InheritedGlobalRules(
+ globalRules: List,
+ settings: ReaderTtsReplacementBookSettings,
+ onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Inherited global rules",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (globalRules.isEmpty()) {
+ Text(
+ text = "No global rules to inherit.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ return
+ }
+ globalRules.forEach { rule ->
+ val enabledHere = rule.id !in settings.disabledGlobalRuleIds
+ ListItem(
+ headlineContent = { Text(rule.summaryText()) },
+ supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") },
+ trailingContent = {
+ Switch(
+ checked = enabledHere,
+ onCheckedChange = { checked ->
+ val disabledIds = if (checked) {
+ settings.disabledGlobalRuleIds - rule.id
+ } else {
+ settings.disabledGlobalRuleIds + rule.id
+ }
+ onSettingsChange(settings.copy(disabledGlobalRuleIds = disabledIds))
+ },
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SuggestionChips(
+ onSuggestionClick: (ReaderTtsReplacementRule) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Suggestions",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
+ AssistChip(
+ onClick = { onSuggestionClick(suggestion) },
+ label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RuleEditorCard(
+ seedRule: ReaderTtsReplacementRule?,
+ onCancel: () -> Unit,
+ onSave: (ReaderTtsReplacementRule) -> Unit,
+) {
+ val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newReplacementRuleId() }
+ val initial = seedRule ?: ReaderTtsReplacementRule(
+ 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) }
+ var previewInput by remember(initial.id) {
+ mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.")
+ }
+
+ val draft = ReaderTtsReplacementRule(
+ id = initial.id,
+ from = from,
+ to = to,
+ enabled = enabled,
+ isRegex = isRegex,
+ matchCase = matchCase,
+ wholeWord = wholeWord,
+ )
+ val validation = ReaderTtsReplacementEngine.validate(draft)
+ val previewOutput = if (validation.isValid) {
+ ReaderTtsReplacementEngine.apply(
+ text = previewInput,
+ preferences = ReaderTtsReplacementPreferences(globalRules = listOf(draft.copy(enabled = true))),
+ ).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 = if (seedRule == null) "New replacement" else "Edit replacement",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ OutlinedTextField(
+ value = from,
+ onValueChange = { from = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("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("Speak as") },
+ singleLine = !isRegex,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ item {
+ FilterChip(
+ selected = enabled,
+ onClick = { enabled = !enabled },
+ label = { Text("Enabled") },
+ leadingIcon = if (enabled) {
+ { Icon(Icons.Default.Check, contentDescription = null) }
+ } else {
+ null
+ },
+ )
+ }
+ item {
+ FilterChip(
+ selected = isRegex,
+ onClick = { isRegex = !isRegex },
+ label = { Text("Regex") },
+ )
+ }
+ item {
+ FilterChip(
+ selected = wholeWord,
+ onClick = { wholeWord = !wholeWord },
+ label = { Text("Whole word") },
+ )
+ }
+ item {
+ FilterChip(
+ selected = matchCase,
+ onClick = { matchCase = !matchCase },
+ label = { Text("Match case") },
+ )
+ }
+ }
+ OutlinedTextField(
+ value = previewInput,
+ onValueChange = { previewInput = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("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("Cancel")
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = { onSave(draft) },
+ enabled = validation.isValid,
+ ) {
+ Text("Save")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReplacementRuleList(
+ rules: List,
+ emptyText: String,
+ onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
+ onEdit: (ReaderTtsReplacementRule) -> Unit,
+ onDelete: (ReaderTtsReplacementRule) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Rules",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (rules.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = emptyText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ rules.forEach { rule ->
+ ListItem(
+ headlineContent = {
+ Text(
+ text = rule.summaryText(),
+ 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 = "Edit")
+ }
+ IconButton(onClick = { onDelete(rule) }) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete")
+ }
+ }
+ },
+ )
+ }
+ }
+}
+
+private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsReplacementRule {
+ return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
+}
+
+private fun ReaderTtsReplacementRule.summaryText(): String {
+ val replacement = to.ifBlank { "silence" }
+ return "$from -> $replacement"
+}
+
+private fun ReaderTtsReplacementRule.optionSummary(): String {
+ val parts = buildList {
+ add(if (isRegex) "Regex" else "Plain text")
+ if (wholeWord) add("whole word")
+ if (matchCase) add("case-sensitive")
+ }
+ return parts.joinToString(" - ")
+}
+
+private fun newReplacementRuleId(): String {
+ return "rule_${System.currentTimeMillis()}"
+}
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 3cca485..b18ef03 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
@@ -40,6 +40,8 @@ import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository
+import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
+import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.json.JSONObject
import org.json.JSONArray
import java.util.UUID
@@ -273,6 +275,10 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = highlightFile.exists()
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
+ "richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
+ )
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) {
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
@@ -284,12 +290,21 @@ class RecentFilesRepository(private val context: Context) {
fun putJsonSafe(key: String, file: File) {
try {
val content = file.readText().trim()
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
+ )
+ }
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ .e(e, "android.folder.export.richTextParseFailed book=$bookId")
+ }
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
}
}
@@ -311,11 +326,18 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs")
+ val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
+ if (hasRichText) {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
+ )
+ }
+
LocalSyncUtils.saveAnnotationSidecar(
context = context,
sourceFolderUri = folderUriString.toUri(),
bookId = bookId,
- jsonPayload = bundleJson.toString(),
+ jsonPayload = canonicalBundleJson,
timestamp = finalTs
)
}
@@ -323,13 +345,24 @@ class RecentFilesRepository(private val context: Context) {
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
- val bundle = JSONObject(jsonString)
+ val bundle = JSONObject(
+ SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
+ )
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
+ "hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
+ )
fun writeSafe(key: String, file: File?) {
if (file != null && bundle.has(key)) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
+ if (key == "text") {
+ Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ "android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
+ )
+ }
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
}
}
diff --git a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt
index 07a8d49..41d0b80 100644
--- a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt
+++ b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt
@@ -1,81 +1,20 @@
package com.aryan.reader.data
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
+import com.aryan.reader.toSharedBookItem
+import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
-@Serializable
-enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
-@Serializable
-enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
-
-@Serializable
-data class SmartRule(
- val field: SmartField,
- val operator: SmartOperator,
- val value: String
-)
-
-@Serializable
-data class SmartCollectionDefinition(
- val matchAll: Boolean = true,
- val rules: List = emptyList()
-)
+typealias SmartField = com.aryan.reader.shared.SmartField
+typealias SmartOperator = com.aryan.reader.shared.SmartOperator
+typealias SmartRule = com.aryan.reader.shared.SmartRule
+typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
object SmartCollectionEngine {
- private val json = Json {
- encodeDefaults = true
- ignoreUnknownKeys = true
- }
+ fun toJson(definition: SmartCollectionDefinition): String =
+ SharedSmartCollectionEngine.toJson(definition)
- fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
+ fun fromJson(json: String?): SmartCollectionDefinition? =
+ SharedSmartCollectionEngine.fromJson(json)
- fun fromJson(json: String?): SmartCollectionDefinition? {
- if (json.isNullOrBlank()) return null
- return try {
- this.json.decodeFromString(json)
- } catch (_: Exception) { null }
- }
-
- fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean {
- if (definition.rules.isEmpty()) return false
-
- val results = definition.rules.map { rule ->
- when (rule.field) {
- SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
- SmartField.AUTHOR -> evaluateString(book.author ?: "", rule)
- SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
- SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule)
- SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
- SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
- }
- }
- return if (definition.matchAll) results.all { it } else results.any { it }
- }
-
- private fun evaluateString(target: String, rule: SmartRule): Boolean {
- return when (rule.operator) {
- SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
- SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
- else -> false
- }
- }
-
- private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
- val ruleValue = rule.value.toFloatOrNull() ?: return false
- return when (rule.operator) {
- SmartOperator.EQUALS -> target == ruleValue
- SmartOperator.GREATER_THAN -> target > ruleValue
- SmartOperator.LESS_THAN -> target < ruleValue
- else -> false
- }
- }
-
- private fun evaluateTags(tags: List, rule: SmartRule): Boolean {
- return when (rule.operator) {
- SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
- SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
- else -> false
- }
- }
+ fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
+ SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
index b5bb7c1..e8649d2 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
@@ -26,6 +26,9 @@ import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.w3c.dom.Element
import org.w3c.dom.Node
@@ -42,6 +45,8 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class EpubParser(private val context: Context) {
+ private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
+
data class EpubDocument(
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
)
@@ -67,6 +72,15 @@ class EpubParser(private val context: Context) {
val depth: Int
)
+ @Serializable
+ private data class EpubExtractionCacheManifest(
+ val bookId: String,
+ val originalBookNameHint: String,
+ val parserVersion: Int,
+ val parseContent: Boolean,
+ val shouldUseToc: Boolean
+ )
+
// EpubFile can still represent in-memory file data during initial parsing before extraction
data class EpubFile(val absPath: String, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
@@ -92,6 +106,9 @@ class EpubParser(private val context: Context) {
companion object {
const val TAG = "EpubParser"
+ private const val BOOK_METADATA_FILE = "book_metadata.json"
+ private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
+ private const val EPUB_EXTRACTION_CACHE_VERSION = 1
}
internal val String.decodedURL: String
@@ -173,8 +190,24 @@ class EpubParser(private val context: Context) {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
- val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
- ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
+ val shouldDeleteExtractionDir = !parseContent && extractionDirOverride == null
+ val extractionDir = if (extractionDirOverride != null) {
+ ImportedFileCache.prepareDirectory(extractionDirOverride)
+ } else if (!parseContent) {
+ ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
+ } else {
+ val activeDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
+ readCachedEpubBook(
+ extractionDir = activeDir,
+ bookId = bookId,
+ originalBookNameHint = originalBookNameHint,
+ shouldUseToc = shouldUseToc
+ )?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
+ return@withContext cachedBook
+ }
+ ImportedFileCache.resetActiveBookDir(context, bookId)
+ }
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map
@@ -190,10 +223,80 @@ class EpubParser(private val context: Context) {
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
originalBookNameHint, parseContent)
+ if (parseContent && extractionDirOverride == null) {
+ writeCachedEpubBook(
+ extractionDir = extractionDir,
+ bookId = bookId,
+ originalBookNameHint = originalBookNameHint,
+ shouldUseToc = shouldUseToc,
+ book = book
+ )
+ }
+ if (shouldDeleteExtractionDir) {
+ extractionDir.deleteRecursively()
+ }
return@withContext book
}
}
+ private fun readCachedEpubBook(
+ extractionDir: File,
+ bookId: String,
+ originalBookNameHint: String,
+ shouldUseToc: Boolean
+ ): EpubBook? {
+ val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
+ val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
+ if (!metadataFile.isFile || !manifestFile.isFile) return null
+
+ return try {
+ val manifest = jsonSerializer.decodeFromString(manifestFile.readText())
+ val isCompatible = manifest.bookId == bookId &&
+ manifest.originalBookNameHint == originalBookNameHint &&
+ manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
+ manifest.parseContent &&
+ manifest.shouldUseToc == shouldUseToc
+
+ if (!isCompatible) {
+ Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
+ return null
+ }
+
+ val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
+ .copy(extractionBasePath = extractionDir.absolutePath)
+
+ cachedBook.takeIf { it.hasReadableExtractedContent() }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to read EPUB extraction cache for bookId=$bookId")
+ null
+ }
+ }
+
+ private fun writeCachedEpubBook(
+ extractionDir: File,
+ bookId: String,
+ originalBookNameHint: String,
+ shouldUseToc: Boolean,
+ book: EpubBook
+ ) {
+ try {
+ File(extractionDir, BOOK_METADATA_FILE).writeText(jsonSerializer.encodeToString(book))
+ File(extractionDir, CACHE_MANIFEST_FILE).writeText(
+ jsonSerializer.encodeToString(
+ EpubExtractionCacheManifest(
+ bookId = bookId,
+ originalBookNameHint = originalBookNameHint,
+ parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
+ parseContent = true,
+ shouldUseToc = shouldUseToc
+ )
+ )
+ )
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to write EPUB extraction cache for bookId=$bookId")
+ }
+ }
+
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map {
val filesMap = mutableMapOf()
zipFile.use { zf ->
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 2d6d743..458b37f 100644
--- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
@@ -24,7 +24,11 @@ class Fb2Parser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
- ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
+ ?: if (parseContent) {
+ ImportedFileCache.prepareActiveBookDir(context, bookId)
+ } else {
+ ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
+ }
var streamToParse = inputStream
try {
diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
index aaecd62..5199091 100644
--- a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
+++ b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
@@ -17,10 +17,18 @@ object ImportedFileCache {
return File(context.cacheDir, activeBookDirName(bookId))
}
- fun prepareActiveBookDir(context: Context, bookId: String): File {
+ fun ensureActiveBookDir(context: Context, bookId: String): File {
+ return activeBookDir(context, bookId).also { it.mkdirs() }
+ }
+
+ fun resetActiveBookDir(context: Context, bookId: String): File {
return prepareDirectory(activeBookDir(context, bookId))
}
+ fun prepareActiveBookDir(context: Context, bookId: String): File {
+ return resetActiveBookDir(context, bookId)
+ }
+
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
val dirName = buildString {
append(TEMP_PREFIX)
diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
index ef46c8f..35fb60f 100644
--- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
@@ -169,7 +169,11 @@ class MobiParser(private val context: Context) {
val bookAuthor = parsedData.author ?: "Unknown Author"
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
- ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
+ ?: if (parseContent) {
+ ImportedFileCache.prepareActiveBookDir(context, bookId)
+ } else {
+ ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
+ }
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }
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 e027fef..4451f12 100644
--- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
@@ -33,7 +33,11 @@ class OdtParser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
- ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
+ ?: if (parseContent) {
+ ImportedFileCache.prepareActiveBookDir(context, bookId)
+ } else {
+ ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
+ }
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)
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 001b5e0..155377c 100644
--- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
+++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
@@ -101,7 +101,7 @@ class SingleFileImporter(private val context: Context) {
try {
FileOutputStream(tempFile).bufferedWriter().use { writer ->
- writer.write("\n\n\n\n${originalBookNameHint}\n")
+ writer.write("\n\n\n\n${generatedHtmlTitle(originalBookNameHint)}\n")
if (isCsv) {
writer.write("\n")
@@ -189,18 +189,20 @@ class SingleFileImporter(private val context: Context) {
)
}
- val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
+ val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
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")
}
}
+ ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint")
@@ -323,18 +325,20 @@ class SingleFileImporter(private val context: Context) {
)
}
- val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
+ val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
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")
}
}
+ ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint")
@@ -484,18 +488,20 @@ class SingleFileImporter(private val context: Context) {
)
}
- val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
+ val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
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")
}
}
+ ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
@@ -511,6 +517,7 @@ class SingleFileImporter(private val context: Context) {
var inStyle = false
var inBody = false
var pageNum = 1
+ val headBuilder = java.lang.StringBuilder()
val currentChapterBuilder = java.lang.StringBuilder()
var line: String?
@@ -531,19 +538,9 @@ class SingleFileImporter(private val context: Context) {
}
if (!inBody) {
- if (trimmed.startsWith("").substringBefore("")
- if (t.isNotBlank()) title = t
- }
- val authorMatch = Regex("]+name=\"author\"[^>]+content=\"([^\"]+)\"").find(
- line
- )
- ?: Regex("]+property=\"article:author\"[^>]+content=\"([^\"]+)\"").find(
- line
- )
- if (authorMatch != null) {
- author = authorMatch.groupValues[1]
- }
+ headBuilder.append(line).append('\n')
+ extractHtmlTitle(headBuilder.toString())?.let { title = it }
+ extractHtmlAuthor(headBuilder.toString())?.let { author = it }
if (trimmed.startsWith("
+
+
+ First page
+
+
+ Second page
+
+
+ """.trimIndent()
+
+ val book = importer.importSingleFile(
+ inputStream = ByteArrayInputStream(html.toByteArray()),
+ type = FileType.HTML,
+ originalBookNameHint = "fallback.html",
+ bookId = "html-book"
+ )
+
+ assertEquals("HTML Title", book.title)
+ assertEquals("HTML Author", book.author)
+ assertEquals(2, book.chapters.size)
+ assertEquals("HTML Title", book.chapters[0].title)
+ assertEquals("Page 2", book.chapters[1].title)
+ assertTrue(book.chapters[0].plainTextContent.contains("First page"))
+ assertTrue(book.chapters[1].plainTextContent.contains("Second page"))
+ assertFalse(File(book.extractionBasePath, "page_1.html").readText().contains("bad()"))
+ assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }"))
+ }
+
+ @Test
+ fun `csv txt wrapper imports as html table`() = runTest {
+ val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache")))
+
+ val book = importer.importSingleFile(
+ inputStream = ByteArrayInputStream("Name,Value\nA & B,".toByteArray()),
+ type = FileType.HTML,
+ originalBookNameHint = "data.csv.txt",
+ bookId = "csv-book"
+ )
+
+ val html = File(book.extractionBasePath, "page_1.html").readText()
+ assertEquals("data.csv", book.title)
+ assertTrue(html.contains(""))
+ assertTrue(html.contains("A & B"))
+ assertTrue(html.contains("<tag>"))
+ }
+
+ private fun contextWithCache(cacheDir: File): Context {
+ val context = mockk()
+ every { context.cacheDir } returns cacheDir
+ return context
+ }
+}
diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt
new file mode 100644
index 0000000..e390f46
--- /dev/null
+++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt
@@ -0,0 +1,200 @@
+package com.aryan.reader.epubreader
+
+import android.webkit.WebView
+import com.aryan.reader.RenderMode
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.runTest
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EpubReaderBridgeAndControlsTest {
+
+ @Test
+ fun `sanitizePlaceholders keeps one header per toolbar section and inserts empty placeholders`() {
+ val input = listOf(
+ FlatToolItem("old_header", FlatItemType.SECTION_HEADER, section = ToolbarSection.BOTTOM),
+ FlatToolItem("format", FlatItemType.TOOL, tool = ReaderTool.FORMAT, section = ToolbarSection.BOTTOM),
+ FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More"),
+ FlatToolItem("reading_mode", FlatItemType.MORE_TOOL, tool = ReaderTool.READING_MODE)
+ )
+
+ val sanitized = sanitizePlaceholders(input)
+
+ assertEquals(
+ listOf(
+ FlatItemType.SECTION_HEADER,
+ FlatItemType.EMPTY_PLACEHOLDER,
+ FlatItemType.SECTION_HEADER,
+ FlatItemType.TOOL,
+ FlatItemType.SECTION_HEADER,
+ FlatItemType.EMPTY_PLACEHOLDER,
+ FlatItemType.MORE_HEADER,
+ FlatItemType.MORE_TOOL
+ ),
+ sanitized.map { it.type }
+ )
+ assertEquals(listOf(ToolbarSection.TOP, ToolbarSection.BOTTOM, ToolbarSection.HIDDEN), sanitized.filter { it.type == FlatItemType.SECTION_HEADER }.map { it.section })
+ assertEquals(ReaderTool.FORMAT, sanitized.single { it.type == FlatItemType.TOOL }.tool)
+ }
+
+ @Test
+ fun `auto scroll bridge invokes chapter end callback`() {
+ var calls = 0
+
+ AutoScrollJsBridge { calls++ }.onChapterEnd()
+
+ assertEquals(1, calls)
+ }
+
+ @Test
+ fun `tts bridge relays nonblank structured text and normalizes blank payloads`() = runTest {
+ val received = CompletableDeferred()
+ val bridge = TtsJsBridge(scope = this, ttsStructuredTextHandler = { received.complete(it) })
+
+ bridge.onStructuredTextExtracted("[{\"text\":\"Hello\"}]")
+
+ assertEquals("[{\"text\":\"Hello\"}]", received.await())
+
+ val blankReceived = CompletableDeferred()
+ TtsJsBridge(scope = this, ttsStructuredTextHandler = { blankReceived.complete(it) }).onStructuredTextExtracted(" ")
+ assertEquals("[]", blankReceived.await())
+ }
+
+ @Test
+ fun `highlight bridge forwards create and click events`() {
+ var created: Triple? = null
+ var clicked: List? = null
+ val bridge = HighlightJsBridge(
+ onCreateCallback = { cfi, text, color -> created = Triple(cfi, text, color) },
+ onClickCallback = { cfi, text, left, top, right, bottom ->
+ clicked = listOf(cfi, text, left, top, right, bottom)
+ }
+ )
+
+ bridge.onHighlightCreated("/4", "Text", "yellow")
+ bridge.onHighlightClicked("/4", "Text", 1, 2, 3, 4)
+
+ assertEquals(Triple("/4", "Text", "yellow"), created)
+ assertEquals(listOf("/4", "Text", 1, 2, 3, 4), clicked)
+ }
+
+ @Test
+ fun `content snippet progress footnote and ai bridges forward callbacks`() = runTest {
+ var requestedChunk = -1
+ var snippet = "" to ""
+ var progressCalls = 0
+ var lastChunk = -1
+ var footnote = ""
+ val aiContent = CompletableDeferred()
+
+ ContentBridge { requestedChunk = it }.requestChunk(7)
+ SnippetJsBridge { cfi, text -> snippet = cfi to text }.onSnippetExtracted("/6", "Snippet")
+ val progress = ProgressJsBridge {
+ progressCalls++
+ lastChunk = it
+ }
+ progress.updateTopChunk(2)
+ progress.updateTopChunk(2)
+ progress.updateTopChunk(3)
+ FootnoteJsBridge { footnote = it }.onFootnoteRequested("Note
")
+ AiJsBridge(scope = this, onContentReady = { aiContent.complete(it) }).onContentExtractedForSummarization("Chapter text")
+
+ assertEquals(7, requestedChunk)
+ assertEquals("/6" to "Snippet", snippet)
+ assertEquals(2, progressCalls)
+ assertEquals(3, lastChunk)
+ assertEquals("Note
", footnote)
+ assertEquals("Chapter text", aiContent.await())
+ }
+
+ @Test
+ fun `ai bridge ignores blank content`() = runTest {
+ var called = false
+
+ AiJsBridge(scope = this, onContentReady = { called = true }).onContentExtractedForSummarization(" ")
+
+ assertFalse(called)
+ }
+
+ @Test
+ fun `cfi bridge parses save bookmark and scroll callbacks with fallback for invalid save json`() {
+ val saved = mutableListOf()
+ val bookmark = mutableListOf()
+ val scrollResults = mutableListOf()
+ val bridge = CfiJsBridge(
+ onCfiReady = { saved.add(it) },
+ onCfiForBookmarkReady = { bookmark.add(it) },
+ onScrollFinishedCallback = { scrollResults.add(it) }
+ )
+
+ bridge.onCfiExtracted(JSONObject().put("cfi", "/4/2:8").put("log", JSONArray()).toString())
+ bridge.onCfiExtracted(JSONObject().put("cfi", "").toString())
+ bridge.onCfiExtracted("broken")
+ bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", "/6/4:1").toString())
+ bridge.onCfiForBookmarkExtracted("broken")
+ bridge.onScrollFinished(true)
+ bridge.onScrollFinished(false)
+
+ assertEquals(listOf("/4/2:8", "/4"), saved)
+ assertEquals(listOf("/6/4:1"), bookmark)
+ assertEquals(listOf(true, false), scrollResults)
+ }
+
+ @Test
+ fun `cfi bridge preserves full reading position cfi payloads for save and bookmark callbacks`() {
+ val saved = mutableListOf()
+ val bookmark = mutableListOf()
+ val bridge = CfiJsBridge(
+ onCfiReady = { saved.add(it) },
+ onCfiForBookmarkReady = { bookmark.add(it) },
+ onScrollFinishedCallback = {}
+ )
+ val cfi = "/6/4[chapter]!/4/2/8:137"
+
+ bridge.onCfiExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray().put("exact")).toString())
+ bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray()).toString())
+
+ assertEquals(listOf(cfi), saved)
+ assertEquals(listOf(cfi), bookmark)
+ }
+
+ @Test
+ fun `updateAutoScrollJs emits start and stop commands`() {
+ val webView = mockk(relaxed = true)
+
+ updateAutoScrollJs(webView, playing = true, speed = 1.25f)
+ updateAutoScrollJs(webView, playing = false, speed = 9f)
+
+ verify { webView.evaluateJavascript("javascript:window.autoScroll.start(1.25);", null) }
+ verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) }
+ }
+
+ @Test
+ fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() {
+ val webView = mockk(relaxed = true)
+ var paginatedStarts = 0
+
+ initiateTtsPlayback(RenderMode.VERTICAL_SCROLL, webView) { paginatedStarts++ }
+ initiateTtsPlayback(RenderMode.PAGINATED, webView) { paginatedStarts++ }
+
+ verify { webView.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) }
+ assertEquals(1, paginatedStarts)
+ }
+
+ @Test
+ fun `reader tool metadata has stable unique names and categories`() {
+ assertEquals(ReaderTool.entries.size, ReaderTool.entries.map { it.name }.toSet().size)
+ assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
+ assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
+ assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
+ }
+}
diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt
new file mode 100644
index 0000000..6dab48b
--- /dev/null
+++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt
@@ -0,0 +1,161 @@
+package com.aryan.reader.epubreader
+
+import android.content.Context
+import com.aryan.reader.R
+import com.aryan.reader.epub.EpubBook
+import com.aryan.reader.epub.EpubChapter
+import com.aryan.reader.paginatedreader.Locator
+import com.aryan.reader.paginatedreader.LocatorConverter
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class EpubReaderContentTest {
+
+ @get:Rule
+ val temp = TemporaryFolder()
+
+ @Test
+ fun `loadChapterContent removes scripts keeps head and chunks body nodes by twenty`() = runTest {
+ val root = temp.newFolder("content")
+ val body = (1..21).joinToString("") { index ->
+ if (index == 3) "Paragraph $index
" else "Paragraph $index
"
+ }
+ writeChapter(root, "chapter.xhtml", "$body")
+ val book = epubBook(root, listOf(chapter("chapter.xhtml")))
+
+ val result = loadChapterContent(
+ context = contextWithStrings(),
+ epubBook = book,
+ chapterIndex = 0,
+ chunkTargetOverride = null,
+ isInitialCfiLoad = false,
+ cfiToLoad = null,
+ locatorConverter = mockk()
+ )
+
+ assertTrue(result.isSuccess)
+ assertEquals("", result.head.trim())
+ assertEquals(2, result.chunks.size)
+ assertFalse(result.chunks.joinToString().contains(""), "")
- .replace(Regex("(?is)