Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,5 +22,6 @@ google-services.json
|
||||||
third_party/pdfium/
|
third_party/pdfium/
|
||||||
*.tgz
|
*.tgz
|
||||||
kcef-bundle/
|
kcef-bundle/
|
||||||
|
kcef-bundle-linux-x64/
|
||||||
cache/
|
cache/
|
||||||
worker/
|
worker/
|
||||||
594
.idea/androidTestResultsUserPreferences.xml
generated
Normal file
594
.idea/androidTestResultsUserPreferences.xml
generated
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidTestResultsUserPreferences">
|
||||||
|
<option name="androidTestResultsTableState">
|
||||||
|
<map>
|
||||||
|
<entry key="-2134210023">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-2134130793">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-2119051914">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-2017379224">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1962843145">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1774674349">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1676769033">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1630125610">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1394902796">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1210366718">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1191428227">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1040221835">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-629314366">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-586759775">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-568130230">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-456965410">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-432405112">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-316488326">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-154934439">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="16756843">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="28629151">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="141172066">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="166282389">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="371509721">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="595536818">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="611512768">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="622787912">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="735688657">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="796826093">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="849020195">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="855385664">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="873767244">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="928257871">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="938129397">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1036269560">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1111069289">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1235102506">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1265980310">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1345410773">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1441470482">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1562612490">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1577909377">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1653774323">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Pixel_9_Pro" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
<entry key="samsung SM-G990B2" value="120" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="1832928338">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_5" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -78,6 +78,19 @@ class AppNavigationTest {
|
||||||
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PPTX,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pptx")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
|
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
|
||||||
// Trigger state change
|
// Trigger state change
|
||||||
|
|
@ -115,4 +128,19 @@ class AppNavigationTest {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Test
|
||||||
|
fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
|
||||||
|
fakeUiState.value = ReaderScreenState(
|
||||||
|
selectedFileType = FileType.PDF,
|
||||||
|
selectedPdfUri = Uri.parse("content://dummy.pdf")
|
||||||
|
)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
||||||
|
|
||||||
|
fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,39 @@ class HtmlParserTest {
|
||||||
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
|
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun htmlToSemanticBlocks_veryLongInlineParagraph_splitsIntoBoundedParagraphs() {
|
||||||
|
val longText = "a".repeat(40_000)
|
||||||
|
val blocks = parse("<p>$longText</p>")
|
||||||
|
val paragraphs = blocks.filterIsInstance<SemanticParagraph>()
|
||||||
|
|
||||||
|
assertThat(paragraphs.size).isAtLeast(2)
|
||||||
|
assertThat(paragraphs.sumOf { it.text.length }).isEqualTo(longText.length)
|
||||||
|
assertThat(paragraphs.all { it.text.length <= 32_000 }).isTrue()
|
||||||
|
assertThat(
|
||||||
|
paragraphs.zipWithNext().all { (previous, next) ->
|
||||||
|
next.startCharOffsetInSource > previous.startCharOffsetInSource
|
||||||
|
}
|
||||||
|
).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun htmlToSemanticBlocks_deepInlineWrapperWithBlockDescendant_parsesWithoutSelectorRecursion() {
|
||||||
|
val mathId = "deep-math"
|
||||||
|
val mathPlaceholder = """<math-placeholder id="$mathId" alttext="Deep math"></math-placeholder>"""
|
||||||
|
val nestedHtml = (1..600).fold(mathPlaceholder) { content, _ ->
|
||||||
|
"<span>$content</span>"
|
||||||
|
}
|
||||||
|
|
||||||
|
val blocks = parse(
|
||||||
|
html = nestedHtml,
|
||||||
|
mathSvgCache = mapOf(mathId to "<svg><text>x</text></svg>")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(blocks).hasSize(1)
|
||||||
|
assertThat(blocks.first()).isInstanceOf(SemanticMath::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
|
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
|
||||||
// SETUP
|
// SETUP
|
||||||
|
|
@ -372,4 +405,4 @@ class HtmlParserTest {
|
||||||
val imageBlock = block as SemanticImage
|
val imageBlock = block as SemanticImage
|
||||||
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
|
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,16 @@
|
||||||
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- PPTX -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" />
|
||||||
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- ODT & FODT -->
|
<!-- ODT & FODT -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -240,4 +250,4 @@
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,34 @@
|
||||||
max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
|
max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#content-container a[href],
|
||||||
|
#content-container a[href]:link,
|
||||||
|
#content-container a[href]:visited,
|
||||||
|
body a[href],
|
||||||
|
body a[href]:link,
|
||||||
|
body a[href]:visited,
|
||||||
|
a[href],
|
||||||
|
a[href]:link,
|
||||||
|
a[href]:visited {
|
||||||
|
color: var(--reader-link, #005FCC) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration-line: underline !important;
|
||||||
|
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-decoration-thickness: max(1px, 0.08em);
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
text-decoration-skip-ink: auto;
|
||||||
|
background-image: linear-gradient(transparent 62%, var(--reader-link-bg, rgba(0, 95, 204, 0.16)) 62%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content-container a[href] *,
|
||||||
|
body a[href] *,
|
||||||
|
a[href] * {
|
||||||
|
color: var(--reader-link, #005FCC) !important;
|
||||||
|
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
figure img {
|
figure img {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +234,7 @@
|
||||||
|
|
||||||
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
||||||
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
|
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
|
||||||
|
var linkPalette = getReaderLinkPalette(isDark, effectiveBg, effectiveText);
|
||||||
|
|
||||||
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
|
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
|
||||||
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
|
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
|
||||||
|
|
@ -221,6 +250,9 @@
|
||||||
:root {
|
:root {
|
||||||
--reader-bg: ${effectiveBg};
|
--reader-bg: ${effectiveBg};
|
||||||
--reader-text: ${effectiveText};
|
--reader-text: ${effectiveText};
|
||||||
|
--reader-link: ${linkPalette.color};
|
||||||
|
--reader-link-decoration: ${linkPalette.color};
|
||||||
|
--reader-link-bg: ${linkPalette.background};
|
||||||
}
|
}
|
||||||
html.${themeClassName}, html.${themeClassName} body {
|
html.${themeClassName}, html.${themeClassName} body {
|
||||||
background-color: var(--reader-bg) !important;
|
background-color: var(--reader-bg) !important;
|
||||||
|
|
@ -228,21 +260,46 @@
|
||||||
${textureCss}
|
${textureCss}
|
||||||
}
|
}
|
||||||
|
|
||||||
html.${themeClassName} a {
|
html.${themeClassName} body a[href],
|
||||||
color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important;
|
html.${themeClassName} body a[href]:link,
|
||||||
|
html.${themeClassName} body a[href]:visited,
|
||||||
|
html.${themeClassName} a[href],
|
||||||
|
html.${themeClassName} a[href]:link,
|
||||||
|
html.${themeClassName} a[href]:visited {
|
||||||
|
color: var(--reader-link) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration-line: underline !important;
|
||||||
|
text-decoration-color: var(--reader-link-decoration) !important;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-decoration-thickness: max(1px, 0.08em);
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
text-decoration-skip-ink: auto;
|
||||||
|
background-image: linear-gradient(transparent 62%, var(--reader-link-bg) 62%);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.${themeClassName} a p,
|
html.${themeClassName} body a[href] p,
|
||||||
html.${themeClassName} a div,
|
html.${themeClassName} body a[href] div,
|
||||||
html.${themeClassName} a span,
|
html.${themeClassName} body a[href] span,
|
||||||
html.${themeClassName} a li,
|
html.${themeClassName} body a[href] li,
|
||||||
html.${themeClassName} a h1,
|
html.${themeClassName} body a[href] h1,
|
||||||
html.${themeClassName} a h2,
|
html.${themeClassName} body a[href] h2,
|
||||||
html.${themeClassName} a h3,
|
html.${themeClassName} body a[href] h3,
|
||||||
html.${themeClassName} a h4,
|
html.${themeClassName} body a[href] h4,
|
||||||
html.${themeClassName} a h5,
|
html.${themeClassName} body a[href] h5,
|
||||||
html.${themeClassName} a h6 {
|
html.${themeClassName} body a[href] h6,
|
||||||
color: var(--reader-text) !important;
|
html.${themeClassName} a[href] p,
|
||||||
|
html.${themeClassName} a[href] div,
|
||||||
|
html.${themeClassName} a[href] span,
|
||||||
|
html.${themeClassName} a[href] li,
|
||||||
|
html.${themeClassName} a[href] h1,
|
||||||
|
html.${themeClassName} a[href] h2,
|
||||||
|
html.${themeClassName} a[href] h3,
|
||||||
|
html.${themeClassName} a[href] h4,
|
||||||
|
html.${themeClassName} a[href] h5,
|
||||||
|
html.${themeClassName} a[href] h6 {
|
||||||
|
color: var(--reader-link) !important;
|
||||||
|
text-decoration-color: var(--reader-link-decoration) !important;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +347,67 @@
|
||||||
} : {r:255,g:255,b:255};
|
} : {r:255,g:255,b:255};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rgbToHex(rgb) {
|
||||||
|
function channel(value) {
|
||||||
|
var hex = Math.max(0, Math.min(255, value)).toString(16);
|
||||||
|
return hex.length < 2 ? '0' + hex : hex;
|
||||||
|
}
|
||||||
|
return '#' + channel(rgb.r) + channel(rgb.g) + channel(rgb.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(first, second) {
|
||||||
|
var firstLum = getLuminance(first.r, first.g, first.b);
|
||||||
|
var secondLum = getLuminance(second.r, second.g, second.b);
|
||||||
|
var lighter = Math.max(firstLum, secondLum);
|
||||||
|
var darker = Math.min(firstLum, secondLum);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReaderLinkPalette(isDark, bgHex, textHex) {
|
||||||
|
var bg = hexToRgb(bgHex);
|
||||||
|
var text = hexToRgb(textHex);
|
||||||
|
var bgLum = getLuminance(bg.r, bg.g, bg.b);
|
||||||
|
var textLum = getLuminance(text.r, text.g, text.b);
|
||||||
|
var candidates = (isDark || bgLum < 0.45)
|
||||||
|
? [
|
||||||
|
{ r: 125, g: 211, b: 252 },
|
||||||
|
{ r: 94, g: 234, b: 212 },
|
||||||
|
{ r: 165, g: 180, b: 252 },
|
||||||
|
{ r: 253, g: 230, b: 138 },
|
||||||
|
{ r: 255, g: 255, b: 255 }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ r: 0, g: 95, b: 204 },
|
||||||
|
{ r: 0, g: 109, b: 117 },
|
||||||
|
{ r: 122, g: 30, b: 82 },
|
||||||
|
{ r: 74, g: 20, b: 140 },
|
||||||
|
{ r: 17, g: 24, b: 39 }
|
||||||
|
];
|
||||||
|
|
||||||
|
var best = candidates[0];
|
||||||
|
var bestScore = -1;
|
||||||
|
for (var i = 0; i < candidates.length; i++) {
|
||||||
|
var candidate = candidates[i];
|
||||||
|
var contrast = contrastRatio(candidate, bg);
|
||||||
|
var separation = Math.abs(getLuminance(candidate.r, candidate.g, candidate.b) - textLum);
|
||||||
|
if (contrast >= 4.5 && separation >= 0.08) {
|
||||||
|
best = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var score = contrast * 10 + separation;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var alpha = bgLum < 0.45 ? 0.24 : 0.16;
|
||||||
|
return {
|
||||||
|
color: rgbToHex(best),
|
||||||
|
background: `rgba(${best.r}, ${best.g}, ${best.b}, ${alpha})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function rgbStringToRgb(rgbStr) {
|
function rgbStringToRgb(rgbStr) {
|
||||||
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||||
if (parts) {
|
if (parts) {
|
||||||
|
|
@ -346,6 +464,7 @@
|
||||||
|
|
||||||
var elements = document.querySelectorAll('[style*="color"]');
|
var elements = document.querySelectorAll('[style*="color"]');
|
||||||
elements.forEach(function(el) {
|
elements.forEach(function(el) {
|
||||||
|
if (el.closest && el.closest('a[href]')) return;
|
||||||
var style = window.getComputedStyle(el);
|
var style = window.getComputedStyle(el);
|
||||||
var colorStr = style.color;
|
var colorStr = style.color;
|
||||||
var rgb = rgbStringToRgb(colorStr);
|
var rgb = rgbStringToRgb(colorStr);
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,58 @@ static bool init_pdfium() {
|
||||||
return get_annot_count_func != nullptr;
|
return get_annot_count_func != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static constexpr int kMaxSafeAnnotCount = 100000;
|
||||||
|
|
||||||
|
static int get_safe_annot_count(void* page) {
|
||||||
|
if (!get_annot_count_func || page == nullptr) return 0;
|
||||||
|
int count = get_annot_count_func(page);
|
||||||
|
if (count < 0 || count > kMaxSafeAnnotCount) {
|
||||||
|
LOGE("Ignoring invalid annotation count: %d", count);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScopedPdfAnnot {
|
||||||
|
public:
|
||||||
|
explicit ScopedPdfAnnot(void* annot) : annot_(annot) {}
|
||||||
|
~ScopedPdfAnnot() {
|
||||||
|
if (annot_ && close_annot_func) {
|
||||||
|
close_annot_func(annot_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopedPdfAnnot(const ScopedPdfAnnot&) = delete;
|
||||||
|
ScopedPdfAnnot& operator=(const ScopedPdfAnnot&) = delete;
|
||||||
|
|
||||||
|
ScopedPdfAnnot(ScopedPdfAnnot&& other) noexcept : annot_(other.annot_) {
|
||||||
|
other.annot_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopedPdfAnnot& operator=(ScopedPdfAnnot&& other) noexcept {
|
||||||
|
if (this != &other) {
|
||||||
|
if (annot_ && close_annot_func) {
|
||||||
|
close_annot_func(annot_);
|
||||||
|
}
|
||||||
|
annot_ = other.annot_;
|
||||||
|
other.annot_ = nullptr;
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* get() const { return annot_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* annot_;
|
||||||
|
};
|
||||||
|
|
||||||
|
static ScopedPdfAnnot get_annot_checked(void* page, jint index) {
|
||||||
|
if (!get_annot_func || page == nullptr || index < 0) return ScopedPdfAnnot(nullptr);
|
||||||
|
int count = get_safe_annot_count(page);
|
||||||
|
if (index >= count) return ScopedPdfAnnot(nullptr);
|
||||||
|
return ScopedPdfAnnot(get_annot_func(page, index));
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jdouble JNICALL
|
extern "C" JNIEXPORT jdouble JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
|
@ -420,17 +472,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
|
||||||
extern "C" JNIEXPORT jstring JNICALL
|
extern "C" JNIEXPORT jstring JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_string_func || pagePtr == 0 || key == nullptr) return nullptr;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
void* annot = get_annot_func(page, index);
|
ScopedPdfAnnot annot = get_annot_checked(page, index);
|
||||||
if (!annot) return nullptr;
|
if (!annot.get()) return nullptr;
|
||||||
|
|
||||||
const char* nativeKey = env->GetStringUTFChars(key, nullptr);
|
const char* nativeKey = env->GetStringUTFChars(key, nullptr);
|
||||||
|
if (!nativeKey) return nullptr;
|
||||||
|
|
||||||
if (strcmp(nativeKey, "IRT") == 0) {
|
if (strcmp(nativeKey, "IRT") == 0) {
|
||||||
if (get_linked_annot_func && close_annot_func) {
|
if (get_linked_annot_func && close_annot_func) {
|
||||||
void* parentAnnot = get_linked_annot_func(annot, "IRT");
|
void* parentAnnot = get_linked_annot_func(annot.get(), "IRT");
|
||||||
if (parentAnnot) {
|
if (parentAnnot) {
|
||||||
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
|
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
|
||||||
jstring result = nullptr;
|
jstring result = nullptr;
|
||||||
|
|
@ -448,7 +501,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0);
|
unsigned long len = get_annot_string_func(annot.get(), nativeKey, nullptr, 0);
|
||||||
|
|
||||||
if (len <= 2) {
|
if (len <= 2) {
|
||||||
env->ReleaseStringUTFChars(key, nativeKey);
|
env->ReleaseStringUTFChars(key, nativeKey);
|
||||||
|
|
@ -456,7 +509,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<unsigned short> buffer(len / 2);
|
std::vector<unsigned short> buffer(len / 2);
|
||||||
get_annot_string_func(annot, nativeKey, buffer.data(), len);
|
get_annot_string_func(annot.get(), nativeKey, buffer.data(), len);
|
||||||
|
|
||||||
jstring result = env->NewString(reinterpret_cast<const jchar*>(buffer.data()), (jsize)(buffer.size() - 1));
|
jstring result = env->NewString(reinterpret_cast<const jchar*>(buffer.data()), (jsize)(buffer.size() - 1));
|
||||||
|
|
||||||
|
|
@ -1592,15 +1645,17 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
|
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return -1;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
int count = get_annot_count_func(page);
|
int count = get_safe_annot_count(page);
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
void* annot = get_annot_func(page, i);
|
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||||
|
if (!annot.get()) continue;
|
||||||
|
|
||||||
float r[4]; // L, B, R, T
|
float r[4]; // L, B, R, T
|
||||||
if (get_annot_rect_func(annot, r)) {
|
if (get_annot_rect_func(annot.get(), r)) {
|
||||||
// FIX: Use min/max to handle inverted PDF rectangles
|
// FIX: Use min/max to handle inverted PDF rectangles
|
||||||
float minX = fmin(r[0], r[2]);
|
float minX = fmin(r[0], r[2]);
|
||||||
float maxX = fmax(r[0], r[2]);
|
float maxX = fmax(r[0], r[2]);
|
||||||
|
|
@ -1608,8 +1663,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
||||||
float maxY = fmax(r[1], r[3]);
|
float maxY = fmax(r[1], r[3]);
|
||||||
|
|
||||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||||
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot));
|
int subtype = get_annot_subtype_func(annot.get());
|
||||||
return get_annot_subtype_func(annot);
|
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, subtype);
|
||||||
|
return subtype;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1619,15 +1675,22 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
int count = get_annot_count_func(page);
|
int count = get_safe_annot_count(page);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
void* annot = get_annot_func(page, i);
|
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||||
|
if (!annot.get()) continue;
|
||||||
|
|
||||||
float rect[4];
|
float rect[4];
|
||||||
if (get_annot_rect_func(annot, rect)) {
|
if (get_annot_rect_func(annot.get(), rect)) {
|
||||||
if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) {
|
float minX = fminf(rect[0], rect[2]);
|
||||||
|
float maxX = fmaxf(rect[0], rect[2]);
|
||||||
|
float minY = fminf(rect[1], rect[3]);
|
||||||
|
float maxY = fmaxf(rect[1], rect[3]);
|
||||||
|
|
||||||
|
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||||
jfloatArray result = env->NewFloatArray(4);
|
jfloatArray result = env->NewFloatArray(4);
|
||||||
env->SetFloatArrayRegion(result, 0, 4, rect);
|
env->SetFloatArrayRegion(result, 0, 4, rect);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -1641,26 +1704,26 @@ extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
|
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
|
||||||
return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
|
return get_safe_annot_count(reinterpret_cast<void*>(pagePtr));
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
|
if (!init_pdfium() || !get_annot_subtype_func || pagePtr == 0) return 0;
|
||||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
|
||||||
return annot ? get_annot_subtype_func(annot) : 0;
|
return annot.get() ? get_annot_subtype_func(annot.get()) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
|
||||||
if (!annot) return nullptr;
|
if (!annot.get()) return nullptr;
|
||||||
|
|
||||||
float rect[4];
|
float rect[4];
|
||||||
if (!get_annot_rect_func(annot, rect)) return nullptr;
|
if (!get_annot_rect_func(annot.get(), rect)) return nullptr;
|
||||||
|
|
||||||
jfloatArray result = env->NewFloatArray(4);
|
jfloatArray result = env->NewFloatArray(4);
|
||||||
env->SetFloatArrayRegion(result, 0, 4, rect);
|
env->SetFloatArrayRegion(result, 0, 4, rect);
|
||||||
|
|
@ -1670,31 +1733,30 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
|
||||||
extern "C" JNIEXPORT jboolean JNICALL
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
|
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return JNI_FALSE;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
int count = get_annot_count_func(page);
|
int count = get_safe_annot_count(page);
|
||||||
void* hitAnnot = nullptr;
|
int hitSubtype = 0;
|
||||||
|
|
||||||
LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count);
|
LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count);
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
void* annot = get_annot_func(page, i);
|
ScopedPdfAnnot annot = get_annot_checked(page, i);
|
||||||
if (!annot) continue;
|
if (!annot.get()) continue;
|
||||||
|
|
||||||
float r[4];
|
float r[4];
|
||||||
if (get_annot_rect_func(annot, r)) {
|
if (get_annot_rect_func(annot.get(), r)) {
|
||||||
float minX = fminf(r[0], r[2]);
|
float minX = fminf(r[0], r[2]);
|
||||||
float maxX = fmaxf(r[0], r[2]);
|
float maxX = fmaxf(r[0], r[2]);
|
||||||
float minY = fminf(r[1], r[3]);
|
float minY = fminf(r[1], r[3]);
|
||||||
float maxY = fmaxf(r[1], r[3]);
|
float maxY = fmaxf(r[1], r[3]);
|
||||||
|
|
||||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||||
hitAnnot = annot;
|
hitSubtype = get_annot_subtype_func(annot.get());
|
||||||
int subtype = get_annot_subtype_func(hitAnnot);
|
LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, hitSubtype);
|
||||||
LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype);
|
|
||||||
if (get_annot_flags_func) {
|
if (get_annot_flags_func) {
|
||||||
int flags = get_annot_flags_func(hitAnnot);
|
int flags = get_annot_flags_func(annot.get());
|
||||||
LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags);
|
LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1702,25 +1764,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hitAnnot) {
|
if (hitSubtype != 0) {
|
||||||
int subtype = get_annot_subtype_func(hitAnnot);
|
if (hitSubtype == 19 || hitSubtype == 20) {
|
||||||
|
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", hitSubtype);
|
||||||
if (subtype == 19 || subtype == 20) {
|
|
||||||
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype);
|
|
||||||
bool anyChanged = false;
|
bool anyChanged = false;
|
||||||
|
|
||||||
for (int j = 0; j < count; j++) {
|
for (int j = 0; j < count; j++) {
|
||||||
void* target = get_annot_func(page, j);
|
ScopedPdfAnnot target = get_annot_checked(page, j);
|
||||||
if (!target || !get_annot_flags_func || !set_annot_flags_func) continue;
|
if (!target.get() || !get_annot_flags_func || !set_annot_flags_func) continue;
|
||||||
|
|
||||||
int flags = get_annot_flags_func(target);
|
int flags = get_annot_flags_func(target.get());
|
||||||
|
|
||||||
// We check for: Invisible (1), Hidden (2), or NoView (32)
|
// We check for: Invisible (1), Hidden (2), or NoView (32)
|
||||||
if (flags & (1 | 2 | 32)) {
|
if (flags & (1 | 2 | 32)) {
|
||||||
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
|
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
|
||||||
|
|
||||||
// Clear bits 1, 2, and 6 (1 + 2 + 32 = 35)
|
// Clear bits 1, 2, and 6 (1 + 2 + 32 = 35)
|
||||||
set_annot_flags_func(target, flags & ~35);
|
set_annot_flags_func(target.get(), flags & ~35);
|
||||||
anyChanged = true;
|
anyChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||||
|
import com.aryan.reader.shared.SharedSettingsHubInput
|
||||||
|
import com.aryan.reader.shared.SharedSettingsPlatform
|
||||||
|
|
||||||
|
fun androidSettingsHubInput(
|
||||||
|
uiState: ReaderScreenState,
|
||||||
|
isOssBuild: Boolean = BuildConfig.FLAVOR == "oss",
|
||||||
|
isOfflineBuild: Boolean = BuildConfig.IS_OFFLINE,
|
||||||
|
isDebugBuild: Boolean = BuildConfig.DEBUG,
|
||||||
|
hideReaderAi: Boolean = false
|
||||||
|
): SharedSettingsHubInput {
|
||||||
|
val supportsSync = !isOssBuild && !isOfflineBuild
|
||||||
|
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
|
||||||
|
val featurePolicy = if (isOfflineBuild) {
|
||||||
|
SharedFeaturePolicy.OssOffline
|
||||||
|
} else {
|
||||||
|
SharedFeaturePolicy.Standard
|
||||||
|
}
|
||||||
|
return SharedSettingsHubInput(
|
||||||
|
platform = SharedSettingsPlatform.ANDROID,
|
||||||
|
featurePolicy = featurePolicy,
|
||||||
|
isDebugBuild = isDebugBuild,
|
||||||
|
isSignedIn = uiState.currentUser != null,
|
||||||
|
isProUser = uiState.isProUser,
|
||||||
|
syncAvailable = supportsSync,
|
||||||
|
folderSyncAvailable = supportsSync,
|
||||||
|
aiSettingsAvailable = supportsOssAiKeys,
|
||||||
|
ttsSettingsAvailable = true,
|
||||||
|
includePdfReaderDefaults = true,
|
||||||
|
includeReaderToolbar = true,
|
||||||
|
includeLanguage = true,
|
||||||
|
includeScreenCaptureProtection = true,
|
||||||
|
includeExternalFileBehavior = true,
|
||||||
|
includeRecentLimit = true,
|
||||||
|
includeCustomFonts = true,
|
||||||
|
includeStrictFileFilter = true,
|
||||||
|
includeHideReaderAi = !isOfflineBuild,
|
||||||
|
includeCloudLocalDataClear = supportsSync,
|
||||||
|
supportProjectAvailable = isOssBuild,
|
||||||
|
isTabsEnabled = uiState.isTabsEnabled,
|
||||||
|
isSyncEnabled = uiState.isSyncEnabled,
|
||||||
|
isFolderSyncEnabled = uiState.isFolderSyncEnabled,
|
||||||
|
useStrictFileFilter = uiState.useStrictFileFilter,
|
||||||
|
isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled,
|
||||||
|
hideReaderAi = hideReaderAi
|
||||||
|
)
|
||||||
|
}
|
||||||
235
app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
Normal file
235
app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||||
|
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||||
|
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||||
|
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
||||||
|
import com.aryan.reader.shared.SharedLibraryStateProjector
|
||||||
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.reduce
|
||||||
|
|
||||||
|
internal object AndroidSharedStateBridge {
|
||||||
|
fun prepareLibraryProjection(
|
||||||
|
input: LibraryProjectionInput,
|
||||||
|
folderPathResolver: FolderPathResolver
|
||||||
|
): AndroidSharedLibraryProjectionContext {
|
||||||
|
val taggedBooks = input.recentFilesFromDb.withResolvedTags(input.dbTags, input.tagRefs)
|
||||||
|
val androidBooksById = taggedBooks
|
||||||
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
.associateBy { it.bookId }
|
||||||
|
val projectionState = input.state.withAndroidFolderFallbacks(androidBooksById.values)
|
||||||
|
val sharedInput = SharedLibraryProjectionInput(
|
||||||
|
state = projectionState.toSharedReaderScreenState(
|
||||||
|
rawBooks = taggedBooks,
|
||||||
|
dbTags = input.dbTags
|
||||||
|
),
|
||||||
|
booksFromStore = taggedBooks
|
||||||
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
.map { it.toSharedProjectionBookItem() },
|
||||||
|
shelfRecords = input.dbShelves.map { it.toSharedShelfRecord() },
|
||||||
|
shelfRefs = input.shelfRefs.map { it.toSharedBookShelfRef() },
|
||||||
|
tags = input.dbTags.map { it.toSharedTag() }
|
||||||
|
)
|
||||||
|
return AndroidSharedLibraryProjectionContext(
|
||||||
|
projectionState = projectionState,
|
||||||
|
sharedInput = sharedInput,
|
||||||
|
androidBooksById = androidBooksById,
|
||||||
|
tagEntitiesById = input.dbTags.associateBy { it.id },
|
||||||
|
folderKeys = projectionState.syncedFolders.map { AndroidSharedFolderProjectionKey(it.uriString, it.name) },
|
||||||
|
folderPathResolver = SharedFolderPathResolver { item ->
|
||||||
|
androidBooksById[item.id]?.let(folderPathResolver::relativeFolderSegments).orEmpty()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun projectLibrary(context: AndroidSharedLibraryProjectionContext): SharedReaderScreenState {
|
||||||
|
return SharedLibraryStateProjector(context.folderPathResolver).project(context.sharedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAndroidState(
|
||||||
|
base: ReaderScreenState,
|
||||||
|
sharedState: SharedReaderScreenState,
|
||||||
|
androidBooksById: Map<String, RecentFileItem>,
|
||||||
|
tagEntitiesById: Map<String, TagEntity>
|
||||||
|
): ReaderScreenState {
|
||||||
|
return sharedState.toAndroidReaderScreenState(
|
||||||
|
base = base,
|
||||||
|
androidBooksById = androidBooksById,
|
||||||
|
tagEntitiesById = tagEntitiesById
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reduceLibraryAction(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
action: SharedLibraryAction
|
||||||
|
): ReaderScreenState {
|
||||||
|
val rawBooks = projectedState.rawLibraryFiles.ifEmpty { current.rawLibraryFiles }
|
||||||
|
val androidBooksById = (rawBooks + current.contextualActionItems).associateBy { it.bookId }
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
|
||||||
|
return current.copy(
|
||||||
|
searchQuery = reduced.searchQuery,
|
||||||
|
sortOrder = reduced.sortOrder.toAndroidSortOrder(),
|
||||||
|
libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(),
|
||||||
|
contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] },
|
||||||
|
contextualActionShelfIds = reduced.selectedShelfIds,
|
||||||
|
libraryScreenStartPage = reduced.libraryScreenStartPage,
|
||||||
|
recentFilesLimit = reduced.recentFilesLimit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reduceAppAction(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
action: SharedAppAction
|
||||||
|
): ReaderScreenState {
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
|
||||||
|
return current.copy(
|
||||||
|
appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(),
|
||||||
|
appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(),
|
||||||
|
appTextDimFactorLight = reduced.appTextDimFactorLight,
|
||||||
|
appTextDimFactorDark = reduced.appTextDimFactorDark,
|
||||||
|
appSeedColor = reduced.appSeedColor,
|
||||||
|
customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTabsEnabled(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
enabled: Boolean
|
||||||
|
): ReaderScreenState {
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.TabsEnabledChanged(enabled))
|
||||||
|
if (enabled) return current.withTabStateFrom(reduced)
|
||||||
|
|
||||||
|
val activeTab = current.activeTabBookId
|
||||||
|
return current.copy(
|
||||||
|
isTabsEnabled = reduced.isTabsEnabled,
|
||||||
|
openTabIds = if (activeTab == null) emptyList() else listOf(activeTab),
|
||||||
|
activeTabBookId = activeTab
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openBookTab(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
bookId: String
|
||||||
|
): ReaderScreenState {
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabOpened(bookId))
|
||||||
|
return current.withTabStateFrom(reduced)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeBookTab(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
bookId: String
|
||||||
|
): ReaderScreenState {
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabClosed(bookId))
|
||||||
|
return current.withTabStateFrom(reduced)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeAllTabs(current: ReaderScreenState, projectedState: ReaderScreenState): ReaderScreenState {
|
||||||
|
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.AllTabsClosed)
|
||||||
|
return current.withTabStateFrom(reduced)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePinsForSelectedBooks(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
isHome: Boolean
|
||||||
|
): ReaderScreenState {
|
||||||
|
val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId }
|
||||||
|
if (selectedIds.isEmpty()) return current
|
||||||
|
|
||||||
|
val currentPins = if (isHome) current.pinnedHomeBookIds else current.pinnedLibraryBookIds
|
||||||
|
val idsToToggle = if (selectedIds.all { it in currentPins }) {
|
||||||
|
selectedIds
|
||||||
|
} else {
|
||||||
|
selectedIds - currentPins
|
||||||
|
}
|
||||||
|
val reduced = idsToToggle.fold(current.toBridgeSharedState(projectedState)) { state, bookId ->
|
||||||
|
state.reduce(
|
||||||
|
if (isHome) {
|
||||||
|
SharedAppAction.HomePinToggled(bookId)
|
||||||
|
} else {
|
||||||
|
SharedAppAction.LibraryPinToggled(bookId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isHome) {
|
||||||
|
current.copy(
|
||||||
|
pinnedHomeBookIds = reduced.pinnedHomeBookIds,
|
||||||
|
contextualActionItems = emptySet()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
current.copy(
|
||||||
|
pinnedLibraryBookIds = reduced.pinnedLibraryBookIds,
|
||||||
|
contextualActionItems = emptySet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceBookSelectionWithVisibleBooks(
|
||||||
|
current: ReaderScreenState,
|
||||||
|
projectedState: ReaderScreenState,
|
||||||
|
visibleBooks: Collection<RecentFileItem>
|
||||||
|
): ReaderScreenState {
|
||||||
|
val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.bookId }
|
||||||
|
val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId }
|
||||||
|
val action = if (visibleIds.isNotEmpty() && selectedIds.containsAll(visibleIds)) {
|
||||||
|
SharedLibraryAction.SelectionCleared
|
||||||
|
} else {
|
||||||
|
SharedLibraryAction.BookSelectionReplaced(visibleIds)
|
||||||
|
}
|
||||||
|
return reduceLibraryAction(
|
||||||
|
current = current,
|
||||||
|
projectedState = projectedState,
|
||||||
|
action = action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
|
||||||
|
return toSharedReaderScreenState(
|
||||||
|
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
|
||||||
|
dbTags = projectedState.allTags.ifEmpty { allTags }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderScreenState.withTabStateFrom(sharedState: SharedReaderScreenState): ReaderScreenState {
|
||||||
|
return copy(
|
||||||
|
isTabsEnabled = sharedState.isTabsEnabled,
|
||||||
|
openTabIds = sharedState.openTabIds,
|
||||||
|
activeTabBookId = sharedState.activeTabBookId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class AndroidSharedLibraryProjectionContext(
|
||||||
|
val projectionState: ReaderScreenState,
|
||||||
|
val sharedInput: SharedLibraryProjectionInput,
|
||||||
|
val androidBooksById: Map<String, RecentFileItem>,
|
||||||
|
val tagEntitiesById: Map<String, TagEntity>,
|
||||||
|
val folderKeys: List<AndroidSharedFolderProjectionKey>,
|
||||||
|
val folderPathResolver: SharedFolderPathResolver
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class AndroidSharedFolderProjectionKey(
|
||||||
|
val uriString: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ReaderScreenState.withAndroidFolderFallbacks(books: Collection<RecentFileItem>): ReaderScreenState {
|
||||||
|
val knownFolders = syncedFolders.mapTo(mutableSetOf()) { it.uriString }
|
||||||
|
val missingFolders = books
|
||||||
|
.mapNotNull { it.sourceFolderUri }
|
||||||
|
.filterTo(linkedSetOf()) { it !in knownFolders }
|
||||||
|
.map { uri -> SyncedFolder(uriString = uri, name = "Local Folder", lastScanTime = 0L) }
|
||||||
|
return if (missingFolders.isEmpty()) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
copy(syncedFolders = syncedFolders + missingFolders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ object AppDestinations {
|
||||||
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
|
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
|
||||||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||||
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||||
|
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||||
|
|
@ -146,7 +147,7 @@ fun AppNavigation(
|
||||||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||||
if (!uiState.isLoading) {
|
if (!uiState.isLoading) {
|
||||||
when (uiState.selectedFileType) {
|
when (uiState.selectedFileType) {
|
||||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> {
|
||||||
if (uiState.selectedPdfUri != null) {
|
if (uiState.selectedPdfUri != null) {
|
||||||
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||||
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
|
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||||
|
|
@ -165,6 +166,11 @@ fun AppNavigation(
|
||||||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FileType.UNKNOWN -> {
|
||||||
|
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
|
||||||
|
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +231,8 @@ fun AppNavigation(
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
} else if (uiState.isLoading) {
|
} else if (uiState.isLoading) {
|
||||||
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
|
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
|
||||||
|
|
@ -301,6 +309,8 @@ fun AppNavigation(
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isLoading -> {
|
isLoading -> {
|
||||||
|
|
@ -361,5 +371,13 @@ fun AppNavigation(
|
||||||
onBackClick = { navController.popBackStackIfReady() }
|
onBackClick = { navController.popBackStackIfReady() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
navController = navController,
|
||||||
|
onBackClick = { navController.popBackStackIfReady() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
|
typealias BannerMessage = com.aryan.reader.shared.BannerMessage
|
||||||
|
typealias UserData = com.aryan.reader.shared.UserData
|
||||||
|
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
|
||||||
|
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
|
||||||
|
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
|
||||||
|
|
||||||
data class ImportResult(
|
data class ImportResult(
|
||||||
val internalUri: Uri,
|
val internalUri: Uri,
|
||||||
|
|
@ -17,37 +21,12 @@ data class ImportResult(
|
||||||
val bundleResult: CalibreBundleResult? = null
|
val bundleResult: CalibreBundleResult? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UserData(
|
|
||||||
val uid: String,
|
|
||||||
val displayName: String?,
|
|
||||||
val photoUrl: String?,
|
|
||||||
val email: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NavigationEvent(
|
data class NavigationEvent(
|
||||||
val route: String,
|
val route: String,
|
||||||
val bookId: String? = null,
|
val bookId: String? = null,
|
||||||
val uri: Uri? = null
|
val uri: Uri? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AppThemeMode {
|
|
||||||
SYSTEM,
|
|
||||||
LIGHT,
|
|
||||||
DARK
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AppContrastOption(val value: Double) {
|
|
||||||
STANDARD(0.0),
|
|
||||||
MEDIUM(0.5),
|
|
||||||
HIGH(1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CustomAppTheme(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val seedColor: androidx.compose.ui.graphics.Color
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
|
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
|
||||||
|
|
||||||
data class DeviceLimitReachedState(
|
data class DeviceLimitReachedState(
|
||||||
|
|
@ -109,7 +88,7 @@ data class ReaderScreenState(
|
||||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||||
val recentFilesLimit: Int = 0,
|
val recentFilesLimit: Int = 0,
|
||||||
val isTabsEnabled: Boolean = false,
|
val isTabsEnabled: Boolean = true,
|
||||||
val openTabIds: List<String> = emptyList(),
|
val openTabIds: List<String> = emptyList(),
|
||||||
val openTabs: List<RecentFileItem> = emptyList(),
|
val openTabs: List<RecentFileItem> = emptyList(),
|
||||||
val activeTabBookId: String? = null,
|
val activeTabBookId: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,9 @@ class BookImporter(private val context: Context) {
|
||||||
when (context.contentResolver.getType(uri)) {
|
when (context.contentResolver.getType(uri)) {
|
||||||
"application/pdf" -> "pdf"
|
"application/pdf" -> "pdf"
|
||||||
"application/epub+zip" -> "epub"
|
"application/epub+zip" -> "epub"
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"
|
||||||
else -> "tmp"
|
else -> "tmp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
|
@ -209,6 +210,7 @@ import javax.crypto.spec.GCMParameterSpec
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||||
const val summarizeEndpoint = "/summarize"
|
const val summarizeEndpoint = "/summarize"
|
||||||
|
|
@ -2487,11 +2489,75 @@ fun importReaderTexture(context: Context, uri: Uri): String? {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_CANVAS_SAFE_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||||
|
private const val DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION = 4096
|
||||||
|
private const val MAX_READER_TEXTURE_DIMENSION_PX = 1024
|
||||||
|
|
||||||
|
fun Bitmap.safeAllocationByteCount(): Long {
|
||||||
|
return try {
|
||||||
|
allocationByteCount.toLong()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
width.toLong() * height.toLong() * 4L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Bitmap.isCanvasSafeBitmap(
|
||||||
|
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||||
|
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
|
||||||
|
): Boolean {
|
||||||
|
return !isRecycled &&
|
||||||
|
width > 0 &&
|
||||||
|
height > 0 &&
|
||||||
|
width <= maxDimension &&
|
||||||
|
height <= maxDimension &&
|
||||||
|
safeAllocationByteCount() <= maxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Bitmap.scaledToCanvasLimit(
|
||||||
|
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||||
|
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
|
||||||
|
): Bitmap {
|
||||||
|
if (isCanvasSafeBitmap(maxBytes, maxDimension)) return this
|
||||||
|
|
||||||
|
val byteScale = sqrt(maxBytes.toDouble() / safeAllocationByteCount().coerceAtLeast(1L).toDouble())
|
||||||
|
val dimensionScale = maxDimension.toDouble() / max(width, height).coerceAtLeast(1).toDouble()
|
||||||
|
val scale = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||||
|
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSampledBitmapFile(path: String, maxDimension: Int): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateBitmapSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||||
|
}
|
||||||
|
return BitmapFactory.decodeFile(path, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||||
|
var sampleSize = 1
|
||||||
|
var sampledWidth = width
|
||||||
|
var sampledHeight = height
|
||||||
|
while (sampledWidth / 2 >= maxDimension || sampledHeight / 2 >= maxDimension) {
|
||||||
|
sampleSize *= 2
|
||||||
|
sampledWidth /= 2
|
||||||
|
sampledHeight /= 2
|
||||||
|
}
|
||||||
|
return sampleSize.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
|
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
|
||||||
if (textureId == null) return null
|
if (textureId == null) return null
|
||||||
return try {
|
return try {
|
||||||
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||||
BitmapFactory.decodeFile(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
decodeSampledBitmapFile(
|
||||||
|
path = textureId.removePrefix(TEXTURE_FILE_PREFIX),
|
||||||
|
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||||
when {
|
when {
|
||||||
|
|
@ -2500,7 +2566,14 @@ fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap?
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bitmap?.asImageBitmap()
|
val safeBitmap = bitmap?.scaledToCanvasLimit(
|
||||||
|
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||||
|
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||||
|
)
|
||||||
|
if (bitmap != null && safeBitmap !== bitmap && !bitmap.isRecycled) {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
safeBitmap?.asImageBitmap()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to load reader texture bitmap: $textureId")
|
Timber.e(e, "Failed to load reader texture bitmap: $textureId")
|
||||||
null
|
null
|
||||||
|
|
@ -2513,8 +2586,24 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
|
||||||
var mimeType = "image/png"
|
var mimeType = "image/png"
|
||||||
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||||
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
||||||
mimeType = imageMimeTypeForExtension(file.extension)
|
mimeType = "image/png"
|
||||||
file.readBytes()
|
val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX)
|
||||||
|
?: return null
|
||||||
|
val bitmap = decodedBitmap.scaledToCanvasLimit(
|
||||||
|
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
|
||||||
|
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
ByteArrayOutputStream().use { out ->
|
||||||
|
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 90, out)
|
||||||
|
out.toByteArray()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (bitmap !== decodedBitmap && !decodedBitmap.isRecycled) {
|
||||||
|
decodedBitmap.recycle()
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||||
when {
|
when {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,728 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.util.Xml
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
internal data class EmbeddedEbookMetadata(
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val cover: EmbeddedEbookCover? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class EmbeddedEbookCover(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val extension: String
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object EmbeddedEbookMetadataExtractor {
|
||||||
|
private const val MAX_XML_ENTRY_BYTES = 512 * 1024
|
||||||
|
private const val MAX_COVER_BYTES = 24 * 1024 * 1024
|
||||||
|
private const val MAX_MOBI_HEADER_RECORD_BYTES = 4 * 1024 * 1024
|
||||||
|
private const val MAX_MOBI_RECORDS = 65_535
|
||||||
|
private const val MAX_MOBI_EXTH_RECORDS = 10_000
|
||||||
|
private const val MOBI_NOT_SET = -1
|
||||||
|
|
||||||
|
private val ebookCoverTypes = setOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
||||||
|
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
fun canExtractEmbeddedCover(type: FileType): Boolean = type in ebookCoverTypes
|
||||||
|
|
||||||
|
fun extract(
|
||||||
|
type: FileType,
|
||||||
|
displayName: String,
|
||||||
|
openStream: () -> InputStream?,
|
||||||
|
extractCover: Boolean = true
|
||||||
|
): EmbeddedEbookMetadata {
|
||||||
|
return when (type) {
|
||||||
|
FileType.EPUB -> extractEpub(openStream, extractCover)
|
||||||
|
FileType.MOBI -> extractMobi(openStream, extractCover)
|
||||||
|
FileType.FB2 -> extractFb2(displayName, openStream, extractCover)
|
||||||
|
else -> EmbeddedEbookMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractEpub(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
|
||||||
|
val containerXml = openStream()?.use { input ->
|
||||||
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
||||||
|
name.equals("META-INF/container.xml", ignoreCase = true)
|
||||||
|
}?.text
|
||||||
|
}
|
||||||
|
val declaredOpfPath = containerXml
|
||||||
|
?.let(::parseEpubRootfilePath)
|
||||||
|
?.let(::normalizeZipPath)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val opfEntry = declaredOpfPath
|
||||||
|
?.let { path ->
|
||||||
|
openStream()?.use { input ->
|
||||||
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
||||||
|
name.equals(path, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?: openStream()?.use { input ->
|
||||||
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
||||||
|
name.endsWith(".opf", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?: return EmbeddedEbookMetadata()
|
||||||
|
val opfPath = opfEntry.path
|
||||||
|
val opf = opfEntry.text
|
||||||
|
|
||||||
|
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
.let { if (it.isBlank()) "" else "$it/" }
|
||||||
|
val manifest = parseEpubManifest(opf)
|
||||||
|
val cover = if (extractCover) {
|
||||||
|
val coverItem = findExplicitEpubCover(opf, manifest)
|
||||||
|
coverItem
|
||||||
|
?.takeIf { it.rasterExtension != null }
|
||||||
|
?.let { item ->
|
||||||
|
val rawPath = resolveEpubZipPath(basePath, item.href)
|
||||||
|
val decodedPath = resolveEpubZipPath(basePath, item.href.percentDecodedOrSelf())
|
||||||
|
listOf(decodedPath, rawPath).distinct().firstNotNullOfOrNull { path ->
|
||||||
|
readZipEntryBytes(openStream, path, MAX_COVER_BYTES)?.let { bytes ->
|
||||||
|
EmbeddedEbookCover(bytes = bytes, extension = item.rasterExtension ?: "png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmbeddedEbookMetadata(
|
||||||
|
title = opf.tagText("title"),
|
||||||
|
author = opf.tagText("creator"),
|
||||||
|
description = opf.tagInnerContent("description"),
|
||||||
|
seriesName = opf.metaContent("calibre:series"),
|
||||||
|
seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(),
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFirstZipTextEntry(
|
||||||
|
input: InputStream,
|
||||||
|
maxBytes: Int,
|
||||||
|
matches: (String) -> Boolean
|
||||||
|
): ZipTextEntry? {
|
||||||
|
var result: ZipTextEntry? = null
|
||||||
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
try {
|
||||||
|
if (entry.isDirectory) continue
|
||||||
|
val name = normalizeZipPath(entry.name)
|
||||||
|
if (matches(name)) {
|
||||||
|
result = zip.readBytesLimited(maxBytes)
|
||||||
|
?.toString(Charsets.UTF_8)
|
||||||
|
?.let { ZipTextEntry(path = name, text = it) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readZipEntryBytes(
|
||||||
|
openStream: () -> InputStream?,
|
||||||
|
targetPath: String,
|
||||||
|
maxBytes: Int
|
||||||
|
): ByteArray? {
|
||||||
|
return openStream()?.use { input ->
|
||||||
|
var result: ByteArray? = null
|
||||||
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
try {
|
||||||
|
if (!entry.isDirectory && normalizeZipPath(entry.name).equals(targetPath, ignoreCase = true)) {
|
||||||
|
result = zip.readBytesLimited(maxBytes)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||||
|
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(containerXml)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.decodeEntities()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
|
||||||
|
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.mapNotNull { match ->
|
||||||
|
val item = match.value
|
||||||
|
val id = item.attr("id")
|
||||||
|
val href = item.attr("href")
|
||||||
|
if (id.isBlank() || href.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
EpubManifestItem(
|
||||||
|
id = id,
|
||||||
|
href = href.decodeEntities(),
|
||||||
|
mediaType = item.attr("media-type"),
|
||||||
|
properties = item.attr("properties")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findExplicitEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
|
||||||
|
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
return manifest.firstOrNull { it.id == coverId }
|
||||||
|
?: manifest.firstOrNull { item ->
|
||||||
|
item.properties.split(Regex("\\s+")).any { it.equals("cover-image", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractFb2(
|
||||||
|
displayName: String,
|
||||||
|
openStream: () -> InputStream?,
|
||||||
|
extractCover: Boolean
|
||||||
|
): EmbeddedEbookMetadata {
|
||||||
|
return openStream()?.use { input ->
|
||||||
|
if (displayName.endsWith(".zip", ignoreCase = true)) {
|
||||||
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
var metadata: EmbeddedEbookMetadata? = null
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
try {
|
||||||
|
if (!entry.isDirectory && entry.name.endsWith(".fb2", ignoreCase = true)) {
|
||||||
|
metadata = parseFb2Xml(zip, extractCover)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata ?: EmbeddedEbookMetadata()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parseFb2Xml(input, extractCover)
|
||||||
|
}
|
||||||
|
} ?: EmbeddedEbookMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFb2Xml(input: InputStream, extractCover: Boolean): EmbeddedEbookMetadata {
|
||||||
|
val parser = Xml.newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
|
parser.setInput(input, null)
|
||||||
|
|
||||||
|
var title: String? = null
|
||||||
|
val authors = mutableListOf<String>()
|
||||||
|
var inAuthor = false
|
||||||
|
var inBody = false
|
||||||
|
var inCoverPage = false
|
||||||
|
val authorParts = mutableListOf<String>()
|
||||||
|
var coverImageId: String? = null
|
||||||
|
var cover: EmbeddedEbookCover? = null
|
||||||
|
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (event) {
|
||||||
|
XmlPullParser.START_TAG -> {
|
||||||
|
when (parser.name.localXmlName()) {
|
||||||
|
"body" -> inBody = true
|
||||||
|
"coverpage" -> {
|
||||||
|
if (!inBody) inCoverPage = true
|
||||||
|
}
|
||||||
|
"author" -> {
|
||||||
|
if (!inBody) {
|
||||||
|
inAuthor = true
|
||||||
|
authorParts.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"book-title" -> {
|
||||||
|
if (title == null) {
|
||||||
|
title = parser.nextTextOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"first-name", "middle-name", "last-name", "nickname" -> {
|
||||||
|
if (inAuthor) {
|
||||||
|
parser.nextTextOrNull()?.let(authorParts::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"image" -> {
|
||||||
|
if (inCoverPage && coverImageId == null) {
|
||||||
|
coverImageId = parser.hrefAttr()?.removePrefix("#")?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"binary" -> {
|
||||||
|
val id = parser.attrValue("id")
|
||||||
|
val contentType = parser.attrValue("content-type")
|
||||||
|
val isExplicitCover = id != null && id == coverImageId
|
||||||
|
if (extractCover && cover == null && isExplicitCover) {
|
||||||
|
val encoded = parser.nextTextOrNull()
|
||||||
|
val decoded = encoded
|
||||||
|
?.takeIf { it.length <= MAX_COVER_BYTES * 2 }
|
||||||
|
?.decodeBase64OrNull()
|
||||||
|
val extension = extensionFromMimeType(contentType)
|
||||||
|
?: id?.rasterExtension()
|
||||||
|
?: decoded?.rasterExtensionFromMagic()
|
||||||
|
?: "jpg"
|
||||||
|
if (decoded != null && decoded.size <= MAX_COVER_BYTES && extension in rasterCoverExtensions) {
|
||||||
|
cover = EmbeddedEbookCover(bytes = decoded, extension = extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> {
|
||||||
|
when (parser.name.localXmlName()) {
|
||||||
|
"body" -> inBody = false
|
||||||
|
"coverpage" -> inCoverPage = false
|
||||||
|
"author" -> {
|
||||||
|
if (inAuthor && authorParts.isNotEmpty()) {
|
||||||
|
authors += authorParts.joinToString(" ").replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
|
inAuthor = false
|
||||||
|
authorParts.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmbeddedEbookMetadata(
|
||||||
|
title = title,
|
||||||
|
author = authors.distinct().joinToString(", ").takeIf { it.isNotBlank() },
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractMobi(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
|
||||||
|
val scan = openStream()?.use { readMobiRecordScan(it) } ?: return EmbeddedEbookMetadata()
|
||||||
|
val header = scan.headerRecord
|
||||||
|
val headerInfo = parseMobiHeaderInfo(header)
|
||||||
|
val charset = when (headerInfo.encoding ?: 1252) {
|
||||||
|
65001 -> Charsets.UTF_8
|
||||||
|
1200 -> Charsets.UTF_16
|
||||||
|
1252 -> Charset.forName("windows-1252")
|
||||||
|
else -> Charsets.UTF_8
|
||||||
|
}
|
||||||
|
val exth = parseMobiExth(header, charset)
|
||||||
|
val cover = if (extractCover) {
|
||||||
|
headerInfo.imageIndex
|
||||||
|
?.let { imageIndex -> exth.coverOffset?.let { imageIndex + it } }
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { recordIndex -> readMobiRecord(openStream, scan.offsets, recordIndex, MAX_COVER_BYTES) }
|
||||||
|
?.takeIf { it.size <= MAX_COVER_BYTES }
|
||||||
|
?.let { imageBytes ->
|
||||||
|
imageBytes.rasterExtensionFromMagic()?.let { extension ->
|
||||||
|
EmbeddedEbookCover(bytes = imageBytes, extension = extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmbeddedEbookMetadata(
|
||||||
|
title = exth.title,
|
||||||
|
author = exth.author,
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMobiRecordScan(input: InputStream): MobiRecordScan? {
|
||||||
|
val buffered = input.buffered()
|
||||||
|
val palmHeader = buffered.readExactBytesOrNull(78) ?: return null
|
||||||
|
val recordCount = palmHeader.u16(76)
|
||||||
|
if (recordCount <= 0 || recordCount > MAX_MOBI_RECORDS) return null
|
||||||
|
|
||||||
|
val recordTable = buffered.readExactBytesOrNull(recordCount * 8) ?: return null
|
||||||
|
val offsets = (0 until recordCount).map { index ->
|
||||||
|
recordTable.u32(index * 8).toInt()
|
||||||
|
}
|
||||||
|
val record0Start = offsets.getOrNull(0) ?: return null
|
||||||
|
val record0End = offsets.getOrNull(1)
|
||||||
|
if (record0Start < 78 + recordTable.size) return null
|
||||||
|
|
||||||
|
val headerRecord = readRecordFromCurrentStream(
|
||||||
|
input = buffered,
|
||||||
|
currentOffset = 78 + recordTable.size,
|
||||||
|
recordStart = record0Start,
|
||||||
|
recordEnd = record0End,
|
||||||
|
maxBytes = MAX_MOBI_HEADER_RECORD_BYTES
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
return MobiRecordScan(offsets = offsets, headerRecord = headerRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMobiRecord(
|
||||||
|
openStream: () -> InputStream?,
|
||||||
|
offsets: List<Int>,
|
||||||
|
recordIndex: Int,
|
||||||
|
maxBytes: Int
|
||||||
|
): ByteArray? {
|
||||||
|
val recordStart = offsets.getOrNull(recordIndex) ?: return null
|
||||||
|
val recordEnd = offsets.getOrNull(recordIndex + 1)
|
||||||
|
if (recordStart < 0) return null
|
||||||
|
|
||||||
|
return openStream()?.use { input ->
|
||||||
|
val buffered = input.buffered()
|
||||||
|
readRecordFromCurrentStream(
|
||||||
|
input = buffered,
|
||||||
|
currentOffset = 0,
|
||||||
|
recordStart = recordStart,
|
||||||
|
recordEnd = recordEnd,
|
||||||
|
maxBytes = maxBytes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readRecordFromCurrentStream(
|
||||||
|
input: InputStream,
|
||||||
|
currentOffset: Int,
|
||||||
|
recordStart: Int,
|
||||||
|
recordEnd: Int?,
|
||||||
|
maxBytes: Int
|
||||||
|
): ByteArray? {
|
||||||
|
if (recordStart < currentOffset) return null
|
||||||
|
if (!input.skipFully((recordStart - currentOffset).toLong())) return null
|
||||||
|
|
||||||
|
val length = recordEnd?.minus(recordStart)
|
||||||
|
return if (length != null) {
|
||||||
|
if (length <= 0 || length > maxBytes) return null
|
||||||
|
input.readExactBytesOrNull(length)
|
||||||
|
} else {
|
||||||
|
input.readBytesLimited(maxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo {
|
||||||
|
if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo()
|
||||||
|
val mobiHeaderLength = header.u32(20).toInt()
|
||||||
|
|
||||||
|
fun u32InHeader(offset: Int): Int? {
|
||||||
|
if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null
|
||||||
|
return header.u32(16 + offset).toInt()
|
||||||
|
.takeIf { it >= 0 && it != MOBI_NOT_SET }
|
||||||
|
}
|
||||||
|
|
||||||
|
return MobiHeaderInfo(
|
||||||
|
encoding = u32InHeader(12),
|
||||||
|
imageIndex = u32InHeader(92)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMobiExth(header: ByteArray, charset: Charset): MobiExthMetadata {
|
||||||
|
if (header.size < 92 || header.asciiAt(16, 4) != "MOBI") return MobiExthMetadata()
|
||||||
|
val mobiHeaderLength = header.u32(20).toInt()
|
||||||
|
val fullNameOffset = header.u32(16 + 68).toInt()
|
||||||
|
val fullNameLength = header.u32(16 + 72).toInt()
|
||||||
|
val fullName = header.safeString(fullNameOffset, fullNameLength, charset)
|
||||||
|
var exthTitle: String? = null
|
||||||
|
var author: String? = null
|
||||||
|
var coverOffset: Int? = null
|
||||||
|
val exthOffsetLong = 16L + mobiHeaderLength
|
||||||
|
if (mobiHeaderLength <= 0 || exthOffsetLong > Int.MAX_VALUE - 12L) {
|
||||||
|
return MobiExthMetadata(title = fullName.takeUnlessBlank())
|
||||||
|
}
|
||||||
|
val exthOffset = exthOffsetLong.toInt()
|
||||||
|
|
||||||
|
if (exthOffset + 12 <= header.size && header.asciiAt(exthOffset, 4) == "EXTH") {
|
||||||
|
val recordCount = header.u32(exthOffset + 8).toInt()
|
||||||
|
var offset = exthOffset + 12
|
||||||
|
repeat(recordCount.coerceIn(0, MAX_MOBI_EXTH_RECORDS)) {
|
||||||
|
if (offset + 8 > header.size) return@repeat
|
||||||
|
val type = header.u32(offset).toInt()
|
||||||
|
val size = header.u32(offset + 4).toInt()
|
||||||
|
if (size < 8 || offset + size > header.size) return@repeat
|
||||||
|
val dataOffset = offset + 8
|
||||||
|
val dataSize = size - 8
|
||||||
|
when (type) {
|
||||||
|
99 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
|
||||||
|
100 -> author = author ?: header.safeString(dataOffset, dataSize, charset)
|
||||||
|
201 -> coverOffset = coverOffset ?: header.u32(dataOffset).toInt().takeIf { dataSize >= 4 }
|
||||||
|
503 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
|
||||||
|
}
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MobiExthMetadata(
|
||||||
|
title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(),
|
||||||
|
author = author.takeUnlessBlank(),
|
||||||
|
coverOffset = coverOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.nextTextOrNull(): String? {
|
||||||
|
return try {
|
||||||
|
nextText()?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.attrValue(localName: String): String? {
|
||||||
|
for (index in 0 until attributeCount) {
|
||||||
|
val name = getAttributeName(index).localXmlName()
|
||||||
|
if (name.equals(localName, ignoreCase = true)) {
|
||||||
|
return getAttributeValue(index)?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.hrefAttr(): String? {
|
||||||
|
return attrValue("href")
|
||||||
|
?: getAttributeValue("http://www.w3.org/1999/xlink", "href")?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readBytesLimited(maxBytes: Int): ByteArray? {
|
||||||
|
val output = java.io.ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
while (true) {
|
||||||
|
val read = read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
total += read
|
||||||
|
if (total > maxBytes) return null
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readExactBytesOrNull(size: Int): ByteArray? {
|
||||||
|
if (size < 0) return null
|
||||||
|
val bytes = ByteArray(size)
|
||||||
|
var offset = 0
|
||||||
|
while (offset < size) {
|
||||||
|
val read = read(bytes, offset, size - offset)
|
||||||
|
if (read == -1) return null
|
||||||
|
offset += read
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.skipFully(bytes: Long): Boolean {
|
||||||
|
var remaining = bytes
|
||||||
|
val scratch = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val skipped = skip(remaining)
|
||||||
|
if (skipped > 0L) {
|
||||||
|
remaining -= skipped
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val read = read(scratch, 0, minOf(scratch.size.toLong(), remaining).toInt())
|
||||||
|
if (read == -1) return false
|
||||||
|
remaining -= read
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeBase64OrNull(): ByteArray? {
|
||||||
|
return runCatching {
|
||||||
|
Base64.getMimeDecoder().decode(this)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.attr(name: String): String {
|
||||||
|
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagText(tag: String): String? {
|
||||||
|
return Regex(
|
||||||
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||||
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||||
|
)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.replace(Regex("<[^>]+>"), " ")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagInnerContent(tag: String): String? {
|
||||||
|
return Regex(
|
||||||
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||||
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||||
|
)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.decodeEntities()
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.metaContent(name: String): String? {
|
||||||
|
return Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(this)
|
||||||
|
.firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeEntities(): String {
|
||||||
|
return replace(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
.replace(Regex("&#(\\d+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeZipPath(path: String): String {
|
||||||
|
val parts = ArrayDeque<String>()
|
||||||
|
path.replace('\\', '/').trimStart('/').split('/').forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||||
|
else -> parts.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveEpubZipPath(basePath: String, href: String): String {
|
||||||
|
return normalizeZipPath(if (href.startsWith('/')) href else basePath + href)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.percentDecodedOrSelf(): String {
|
||||||
|
return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.localXmlName(): String = substringAfter(':').lowercase()
|
||||||
|
|
||||||
|
private fun String.rasterExtension(): String? {
|
||||||
|
val extension = substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
return extension.takeIf { it in rasterCoverExtensions }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extensionFromMimeType(mimeType: String?): String? {
|
||||||
|
return when (mimeType?.lowercase()) {
|
||||||
|
"image/jpeg", "image/jpg" -> "jpg"
|
||||||
|
"image/png" -> "png"
|
||||||
|
"image/gif" -> "gif"
|
||||||
|
"image/webp" -> "webp"
|
||||||
|
"image/bmp" -> "bmp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.rasterExtensionFromMagic(): String? {
|
||||||
|
return when {
|
||||||
|
size >= 3 &&
|
||||||
|
(this[0].toInt() and 0xFF) == 0xFF &&
|
||||||
|
(this[1].toInt() and 0xFF) == 0xD8 &&
|
||||||
|
(this[2].toInt() and 0xFF) == 0xFF -> "jpg"
|
||||||
|
size >= 8 && asciiAt(1, 3) == "PNG" -> "png"
|
||||||
|
size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "gif"
|
||||||
|
size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "webp"
|
||||||
|
size >= 2 && asciiAt(0, 2) == "BM" -> "bmp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.u16(offset: Int): Int {
|
||||||
|
if (offset + 2 > size) return 0
|
||||||
|
return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.u32(offset: Int): Long {
|
||||||
|
if (offset + 4 > size) return 0
|
||||||
|
return ((this[offset].toLong() and 0xFF) shl 24) or
|
||||||
|
((this[offset + 1].toLong() and 0xFF) shl 16) or
|
||||||
|
((this[offset + 2].toLong() and 0xFF) shl 8) or
|
||||||
|
(this[offset + 3].toLong() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.asciiAt(offset: Int, length: Int): String {
|
||||||
|
if (offset < 0 || offset + length > size) return ""
|
||||||
|
return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? {
|
||||||
|
if (offset < 0 || length <= 0 || offset + length > size) return null
|
||||||
|
return copyOfRange(offset, offset + length).toString(charset)
|
||||||
|
.trim('\u0000', ' ', '\n', '\r', '\t')
|
||||||
|
.takeUnlessBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.takeUnlessBlank(): String? {
|
||||||
|
return this?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EpubManifestItem.rasterExtension: String?
|
||||||
|
get() {
|
||||||
|
href.rasterExtension()?.let { return it }
|
||||||
|
return extensionFromMimeType(mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ZipTextEntry(
|
||||||
|
val path: String,
|
||||||
|
val text: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EpubManifestItem(
|
||||||
|
val id: String,
|
||||||
|
val href: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val properties: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MobiHeaderInfo(
|
||||||
|
val encoding: Int? = null,
|
||||||
|
val imageIndex: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MobiRecordScan(
|
||||||
|
val offsets: List<Int>,
|
||||||
|
val headerRecord: ByteArray
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MobiExthMetadata(
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val coverOffset: Int? = null
|
||||||
|
)
|
||||||
|
}
|
||||||
115
app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
Normal file
115
app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.aryan.reader.data.BookMetadataEdit
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AndroidEpubMetadataEditResult(
|
||||||
|
val metadata: SharedEpubMetadataSnapshot,
|
||||||
|
val fileSize: Long,
|
||||||
|
val fileContentModifiedTimestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
class EpubMetadataFileEditor(private val context: Context) {
|
||||||
|
suspend fun writeMetadata(
|
||||||
|
item: RecentFileItem,
|
||||||
|
metadata: BookMetadataEdit
|
||||||
|
): Result<AndroidEpubMetadataEditResult> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
require(item.type == FileType.EPUB) { "Only EPUB metadata editing is supported." }
|
||||||
|
val sourceUri = item.uriString?.toUri() ?: error("Book file is not available.")
|
||||||
|
val token = UUID.randomUUID().toString()
|
||||||
|
val sourceCopy = File(context.cacheDir, "epub_metadata_source_$token.epub")
|
||||||
|
val editedCopy = File(context.cacheDir, "epub_metadata_edited_$token.epub")
|
||||||
|
|
||||||
|
try {
|
||||||
|
copyUriToFile(sourceUri, sourceCopy)
|
||||||
|
val rewritten = SharedEpubMetadataEditor.rewrite(
|
||||||
|
source = sourceCopy,
|
||||||
|
destination = editedCopy,
|
||||||
|
update = SharedEpubMetadataUpdate(
|
||||||
|
title = metadata.title,
|
||||||
|
author = metadata.author,
|
||||||
|
description = metadata.description,
|
||||||
|
seriesName = metadata.seriesName,
|
||||||
|
seriesIndex = metadata.seriesIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
backupOriginalIfNeeded(item, sourceCopy)
|
||||||
|
replaceUriBytes(sourceUri, editedCopy)
|
||||||
|
|
||||||
|
AndroidEpubMetadataEditResult(
|
||||||
|
metadata = rewritten,
|
||||||
|
fileSize = queryFileSize(sourceUri).takeIf { it > 0L } ?: editedCopy.length(),
|
||||||
|
fileContentModifiedTimestamp = queryLastModified(sourceUri).takeIf { it > 0L }
|
||||||
|
?: System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
sourceCopy.delete()
|
||||||
|
editedCopy.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyUriToFile(uri: Uri, destination: File) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
destination.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: error("Unable to read EPUB source.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceUriBytes(uri: Uri, editedFile: File) {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
val target = File(uri.path ?: error("Invalid file URI."))
|
||||||
|
editedFile.inputStream().use { input ->
|
||||||
|
target.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||||
|
editedFile.inputStream().use { input -> input.copyTo(output) }
|
||||||
|
} ?: error("Unable to write EPUB source.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backupOriginalIfNeeded(item: RecentFileItem, sourceCopy: File) {
|
||||||
|
val backupFile = File(
|
||||||
|
File(context.filesDir, "metadata_backups").apply { mkdirs() },
|
||||||
|
"${item.bookId.toSafeBackupName()}.epub"
|
||||||
|
)
|
||||||
|
if (!backupFile.exists()) {
|
||||||
|
sourceCopy.inputStream().use { input ->
|
||||||
|
backupFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryFileSize(uri: Uri): Long {
|
||||||
|
return if (uri.scheme == "file") {
|
||||||
|
uri.path?.let { File(it).length() } ?: 0L
|
||||||
|
} else {
|
||||||
|
DocumentFile.fromSingleUri(context, uri)?.length() ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryLastModified(uri: Uri): Long {
|
||||||
|
return if (uri.scheme == "file") {
|
||||||
|
uri.path?.let { File(it).lastModified() } ?: 0L
|
||||||
|
} else {
|
||||||
|
DocumentFile.fromSingleUri(context, uri)?.lastModified() ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toSafeBackupName(): String {
|
||||||
|
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||||
|
}
|
||||||
|
|
@ -1,130 +1,66 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
private val codeOrDataExtensions = setOf(
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
"csv",
|
|
||||||
"tsv",
|
|
||||||
"json",
|
|
||||||
"xml",
|
|
||||||
"log",
|
|
||||||
"java",
|
|
||||||
"kt",
|
|
||||||
"py",
|
|
||||||
"js",
|
|
||||||
"cpp",
|
|
||||||
"c",
|
|
||||||
"cs",
|
|
||||||
"rb",
|
|
||||||
"go"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val manualOnlyReaderMimeTypes = setOf(
|
|
||||||
"text/csv",
|
|
||||||
"text/comma-separated-values",
|
|
||||||
"text/tab-separated-values",
|
|
||||||
"application/json",
|
|
||||||
"application/xml",
|
|
||||||
"text/xml",
|
|
||||||
"text/x-java-source",
|
|
||||||
"text/x-python",
|
|
||||||
"text/x-kotlin",
|
|
||||||
"text/javascript",
|
|
||||||
"application/javascript",
|
|
||||||
"text/x-c",
|
|
||||||
"text/x-c++",
|
|
||||||
"text/x-csharp",
|
|
||||||
"text/x-ruby",
|
|
||||||
"text/x-go",
|
|
||||||
"text/x-log"
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
return SharedFileCapabilities.resolveFileTypeForName(fileName)
|
||||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
}
|
||||||
|
|
||||||
return when {
|
internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
|
||||||
effectiveName.endsWith(".cbz") -> FileType.CBZ
|
val normalizedMimeType = mimeType
|
||||||
effectiveName.endsWith(".cbr") -> FileType.CBR
|
?.substringBefore(';')
|
||||||
effectiveName.endsWith(".cb7") -> FileType.CB7
|
?.trim()
|
||||||
effectiveName.endsWith(".pdf") -> FileType.PDF
|
?.lowercase()
|
||||||
effectiveName.endsWith(".epub") -> FileType.EPUB
|
return when (normalizedMimeType) {
|
||||||
effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI
|
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||||
effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2
|
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||||
effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||||
effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
|
||||||
effectiveName.endsWith(".docx") -> FileType.DOCX
|
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||||
effectiveName.endsWith(".odt") -> FileType.ODT
|
when {
|
||||||
effectiveName.endsWith(".fodt") -> FileType.FODT
|
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
|
||||||
effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
|
||||||
effectiveName.endsWith(".txt") -> FileType.TXT
|
else -> null
|
||||||
else -> null
|
}
|
||||||
|
}
|
||||||
|
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
|
||||||
|
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
|
||||||
|
}
|
||||||
|
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||||
|
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||||
|
}
|
||||||
|
"application/pdf" -> FileType.PDF
|
||||||
|
"application/epub+zip" -> FileType.EPUB
|
||||||
|
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||||
|
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
||||||
|
"text/markdown", "text/x-markdown" -> FileType.MD
|
||||||
|
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||||
|
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
|
||||||
|
"application/json", "application/xml", "text/xml",
|
||||||
|
"text/x-java-source", "text/x-python", "text/x-kotlin",
|
||||||
|
"text/javascript", "application/javascript",
|
||||||
|
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
||||||
|
"text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT
|
||||||
|
else -> resolveFileTypeFromName(fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||||
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
return SharedFileCapabilities.isCodeOrDataFileName(fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
||||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
|
return SharedFileCapabilities.isManualOnlyReaderFileName(fileName)
|
||||||
return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||||
val normalized = mimeType?.lowercase() ?: return false
|
return SharedFileCapabilities.isManualOnlyReaderMimeType(mimeType)
|
||||||
return normalized in manualOnlyReaderMimeTypes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
||||||
if (isManualOnlyReaderFileName(name)) return false
|
return SharedFileCapabilities.isLocalFolderSyncEligibleFile(name, mimeType)
|
||||||
if (resolveFileTypeFromName(name) != null) return true
|
|
||||||
return !isManualOnlyReaderMimeType(mimeType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
|
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
|
||||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
return SharedFileCapabilities.fileExtensionSuffixForName(fileName)
|
||||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
|
||||||
val effectiveSuffix = when {
|
|
||||||
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
|
||||||
effectiveName.endsWith(".markdown") -> ".markdown"
|
|
||||||
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
|
||||||
effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}"
|
|
||||||
else -> null
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
return if (effectiveName != lowerName && lowerName.endsWith(".txt")) {
|
|
||||||
"$effectiveSuffix.txt"
|
|
||||||
} else {
|
|
||||||
effectiveSuffix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.withTransparentTextSuffix(): String {
|
|
||||||
if (!endsWith(".txt")) return this
|
|
||||||
val innerName = removeSuffix(".txt")
|
|
||||||
if (innerName.isBlank() || !innerName.contains('.')) return this
|
|
||||||
return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? {
|
|
||||||
return when {
|
|
||||||
fileName.endsWith(".cbz") -> FileType.CBZ
|
|
||||||
fileName.endsWith(".cbr") -> FileType.CBR
|
|
||||||
fileName.endsWith(".cb7") -> FileType.CB7
|
|
||||||
fileName.endsWith(".pdf") -> FileType.PDF
|
|
||||||
fileName.endsWith(".epub") -> FileType.EPUB
|
|
||||||
fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI
|
|
||||||
fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2
|
|
||||||
fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD
|
|
||||||
fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML
|
|
||||||
fileName.endsWith(".docx") -> FileType.DOCX
|
|
||||||
fileName.endsWith(".odt") -> FileType.ODT
|
|
||||||
fileName.endsWith(".fodt") -> FileType.FODT
|
|
||||||
fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.extensionAfterLastDot(): String? {
|
|
||||||
val dotIndex = lastIndexOf('.')
|
|
||||||
return if (dotIndex in 0..<lastIndex) substring(dotIndex + 1) else null
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,14 @@ class FolderSyncWorker(
|
||||||
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
allowedFileTypes.addAll(FileType.entries)
|
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
|
||||||
}
|
}
|
||||||
folders.add(Pair(uri, allowedFileTypes))
|
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { Timber.e(e) }
|
} catch (e: Exception) { Timber.e(e) }
|
||||||
} else {
|
} else {
|
||||||
val single = prefs.getString("synced_folder_uri", null)
|
val single = prefs.getString("synced_folder_uri", null)
|
||||||
if (single != null) folders.add(Pair(single, FileType.entries.toSet()))
|
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folders.isEmpty()) {
|
if (folders.isEmpty()) {
|
||||||
|
|
@ -380,7 +380,7 @@ class FolderSyncWorker(
|
||||||
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||||
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||||
coverImagePath = null,
|
coverImagePath = null,
|
||||||
title = remoteMeta?.title ?: name,
|
title = name.substringBeforeLast('.', name),
|
||||||
author = remoteMeta?.author,
|
author = remoteMeta?.author,
|
||||||
isAvailable = true,
|
isAvailable = true,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
|
|
@ -395,7 +395,8 @@ class FolderSyncWorker(
|
||||||
customName = remoteMeta?.customName,
|
customName = remoteMeta?.customName,
|
||||||
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
||||||
locatorCharOffset = remoteMeta?.locatorCharOffset,
|
locatorCharOffset = remoteMeta?.locatorCharOffset,
|
||||||
fileSize = size
|
fileSize = size,
|
||||||
|
fileContentModifiedTimestamp = lastModified
|
||||||
)
|
)
|
||||||
newOrUpdatedItems.add(newItem)
|
newOrUpdatedItems.add(newItem)
|
||||||
newBooks++
|
newBooks++
|
||||||
|
|
@ -403,13 +404,28 @@ class FolderSyncWorker(
|
||||||
var needsUpdate = false
|
var needsUpdate = false
|
||||||
var updatedItem = existingItem
|
var updatedItem = existingItem
|
||||||
|
|
||||||
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
val modifiedChanged = lastModified > 0L &&
|
||||||
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
existingItem.fileContentModifiedTimestamp != lastModified
|
||||||
|
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
|
||||||
|
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
|
||||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||||
updatedItem = updatedItem.copy(
|
updatedItem = updatedItem.copy(
|
||||||
fileSize = size,
|
fileSize = size,
|
||||||
|
fileContentModifiedTimestamp = lastModified,
|
||||||
lastModifiedTimestamp = lastModified,
|
lastModifiedTimestamp = lastModified,
|
||||||
folderTextMetadataParsed = false
|
coverImagePath = null,
|
||||||
|
title = name.substringBeforeLast('.', name),
|
||||||
|
author = null,
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
originalTitle = null,
|
||||||
|
originalAuthor = null,
|
||||||
|
originalSeriesName = null,
|
||||||
|
originalSeriesIndex = null,
|
||||||
|
originalDescription = null,
|
||||||
|
folderTextMetadataParsed = false,
|
||||||
|
folderCoverMetadataParsed = false
|
||||||
)
|
)
|
||||||
needsUpdate = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
|
@ -500,7 +516,7 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||||
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||||
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
|
ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
|
||||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||||
.setInputData(
|
.setInputData(
|
||||||
androidx.work.Data.Builder()
|
androidx.work.Data.Builder()
|
||||||
|
|
@ -514,7 +530,7 @@ class FolderSyncWorker(
|
||||||
metaRequest
|
metaRequest
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
|
ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -602,15 +618,7 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
return when (mimeType) {
|
return resolveFileTypeFromMetadata(name, mimeType)
|
||||||
"application/pdf" -> FileType.PDF
|
|
||||||
"application/epub+zip" -> FileType.EPUB
|
|
||||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
|
||||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
|
||||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
|
||||||
else -> resolveFileTypeFromName(name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.VerifiedUser
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
import androidx.compose.material.icons.outlined.AccountCircle
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||||
|
|
@ -140,10 +141,8 @@ import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -171,15 +170,16 @@ fun HomeScreen(
|
||||||
|
|
||||||
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
|
val recentFilesForHome = uiState.recentFiles
|
||||||
val recentFilesForHome = screenModel.recentFiles
|
val openTabs = uiState.openTabs
|
||||||
val openTabs = screenModel.openTabs
|
val selectedContextItems = uiState.contextualActionItems
|
||||||
val selectedContextItems = screenModel.selectedItems
|
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
||||||
val isContextualModeActive = screenModel.isContextualModeActive
|
val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())
|
||||||
|
val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val deviceLimitState = screenModel.deviceLimitState
|
val deviceLimitState = uiState.deviceLimitState
|
||||||
|
|
||||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||||
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -224,15 +224,6 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.bannerMessage) {
|
|
||||||
uiState.bannerMessage?.let { msg ->
|
|
||||||
if (!msg.isPersistent) {
|
|
||||||
delay(3000L)
|
|
||||||
viewModel.bannerMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.errorMessage) {
|
LaunchedEffect(uiState.errorMessage) {
|
||||||
uiState.errorMessage?.let { message ->
|
uiState.errorMessage?.let { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
|
|
@ -319,6 +310,12 @@ fun HomeScreen(
|
||||||
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSettingsClick = {
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
|
||||||
|
}
|
||||||
|
},
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||||
)
|
)
|
||||||
|
|
@ -353,6 +350,9 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAppThemeClick = { showAppThemePanel = true },
|
onAppThemeClick = { showAppThemePanel = true },
|
||||||
|
onSettingsClick = {
|
||||||
|
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
|
||||||
|
},
|
||||||
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||||
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
||||||
onLanguageClick = { showLanguageDialog = true },
|
onLanguageClick = { showLanguageDialog = true },
|
||||||
|
|
@ -394,8 +394,8 @@ fun HomeScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
if (screenModel.isEmpty) {
|
if (isHomeEmpty) {
|
||||||
if (screenModel.isLibraryEmpty) {
|
if (isLibraryEmpty) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.your_library_empty),
|
title = stringResource(R.string.your_library_empty),
|
||||||
message = stringResource(R.string.your_library_empty_desc),
|
message = stringResource(R.string.your_library_empty_desc),
|
||||||
|
|
@ -502,8 +502,14 @@ fun HomeScreen(
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onUpdateName = { newName ->
|
onSaveMetadata = { metadata ->
|
||||||
viewModel.updateCustomName(item.bookId, newName)
|
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||||
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(item.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||||
},
|
},
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
)
|
)
|
||||||
|
|
@ -793,16 +799,8 @@ fun RecentFileCard(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
isDownloading: Boolean,
|
isDownloading: Boolean,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
|
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
|
||||||
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
|
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
|
||||||
val placeholder = when (item.type) {
|
|
||||||
FileType.PDF -> R.drawable.pdf_placeholder
|
|
||||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
|
||||||
}
|
|
||||||
val imageModel = remember(item.coverImagePath) {
|
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -827,24 +825,25 @@ fun RecentFileCard(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(0.74f)
|
.aspectRatio(0.74f)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
ThemedBookCover(
|
||||||
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
|
item = item,
|
||||||
.fallback(placeholder).crossfade(true).build(),
|
|
||||||
contentDescription = item.displayName,
|
contentDescription = item.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
if (!item.coverImagePath.isNullOrBlank()) {
|
||||||
modifier = Modifier.fillMaxSize().background(
|
Box(
|
||||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
modifier = Modifier.fillMaxSize().background(
|
||||||
0f to Color.Black.copy(alpha = 0.15f),
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
0.3f to Color.Transparent,
|
0f to Color.Black.copy(alpha = 0.15f),
|
||||||
0.6f to Color.Transparent,
|
0.3f to Color.Transparent,
|
||||||
1f to Color.Black.copy(alpha = 0.5f)
|
0.6f to Color.Transparent,
|
||||||
|
1f to Color.Black.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
|
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
|
||||||
FileStatusBadges(
|
FileStatusBadges(
|
||||||
|
|
@ -1024,6 +1023,7 @@ fun DefaultTopAppBar(
|
||||||
onExternalFileBehaviorClick: () -> Unit,
|
onExternalFileBehaviorClick: () -> Unit,
|
||||||
onStrictFilterToggleClick: () -> Unit,
|
onStrictFilterToggleClick: () -> Unit,
|
||||||
onAppThemeClick: () -> Unit,
|
onAppThemeClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
onTestPanelDetectionClick: () -> Unit,
|
onTestPanelDetectionClick: () -> Unit,
|
||||||
onTestSpeechBubbleDetectionClick: () -> Unit,
|
onTestSpeechBubbleDetectionClick: () -> Unit,
|
||||||
onLanguageClick: () -> Unit,
|
onLanguageClick: () -> Unit,
|
||||||
|
|
@ -1048,6 +1048,9 @@ fun DefaultTopAppBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, actions = {
|
}, actions = {
|
||||||
|
IconButton(onClick = onSettingsClick) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = onAppThemeClick) {
|
IconButton(onClick = onAppThemeClick) {
|
||||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme))
|
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme))
|
||||||
|
|
@ -1134,19 +1137,21 @@ fun DefaultTopAppBar(
|
||||||
showOptionsMenu = false
|
showOptionsMenu = false
|
||||||
})
|
})
|
||||||
|
|
||||||
DropdownMenuItem(
|
if (!BuildConfig.IS_OFFLINE) {
|
||||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
DropdownMenuItem(
|
||||||
onClick = {
|
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||||
onToggleHideReaderAi()
|
onClick = {
|
||||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
onToggleHideReaderAi()
|
||||||
showOptionsMenu = false
|
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||||
},
|
showOptionsMenu = false
|
||||||
trailingIcon = {
|
},
|
||||||
if (hideReaderAiFeatures) {
|
trailingIcon = {
|
||||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
if (hideReaderAiFeatures) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||||
|
|
@ -1204,6 +1209,7 @@ private fun AppDrawerContent(
|
||||||
onSyncUpsellClick: () -> Unit,
|
onSyncUpsellClick: () -> Unit,
|
||||||
onFontsClick: () -> Unit,
|
onFontsClick: () -> Unit,
|
||||||
onAiSettingsClick: () -> Unit,
|
onAiSettingsClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
onFolderSyncToggle: (Boolean) -> Unit
|
onFolderSyncToggle: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -1366,6 +1372,14 @@ private fun AppDrawerContent(
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
|
label = { Text("Settings") },
|
||||||
|
selected = false,
|
||||||
|
onClick = onSettingsClick,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
|
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
|
||||||
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
|
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,21 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
|
||||||
enum class AddBooksSource {
|
typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
|
||||||
UNSHELVED,
|
typealias FileType = com.aryan.reader.shared.FileType
|
||||||
ALL_BOOKS
|
typealias RenderMode = com.aryan.reader.shared.RenderMode
|
||||||
}
|
typealias SortOrder = com.aryan.reader.shared.SortOrder
|
||||||
|
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
|
||||||
|
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
|
||||||
|
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
|
||||||
|
|
||||||
enum class FileType {
|
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
|
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
||||||
}
|
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
||||||
|
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
||||||
internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
|
||||||
|
|
||||||
internal val EPUB_READER_FILE_TYPES = setOf(
|
|
||||||
FileType.EPUB,
|
|
||||||
FileType.MOBI,
|
|
||||||
FileType.MD,
|
|
||||||
FileType.TXT,
|
|
||||||
FileType.HTML,
|
|
||||||
FileType.FB2,
|
|
||||||
FileType.DOCX,
|
|
||||||
FileType.ODT,
|
|
||||||
FileType.FODT
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class RenderMode {
|
|
||||||
VERTICAL_SCROLL, PAGINATED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SyncedFolder(
|
|
||||||
val uriString: String,
|
|
||||||
val name: String,
|
|
||||||
val lastScanTime: Long,
|
|
||||||
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
||||||
|
|
||||||
|
|
@ -54,33 +35,3 @@ data class Shelf(
|
||||||
val directBookCount: Int get() = directBooks.size
|
val directBookCount: Int get() = directBooks.size
|
||||||
val childShelfCount: Int get() = childShelfIds.size
|
val childShelfCount: Int get() = childShelfIds.size
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class SortOrder {
|
|
||||||
RECENT,
|
|
||||||
TITLE_ASC,
|
|
||||||
AUTHOR_ASC,
|
|
||||||
PERCENT_ASC,
|
|
||||||
PERCENT_DESC,
|
|
||||||
SIZE_ASC,
|
|
||||||
SIZE_DESC
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ReadStatusFilter {
|
|
||||||
ALL,
|
|
||||||
UNREAD,
|
|
||||||
IN_PROGRESS,
|
|
||||||
COMPLETED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LibraryFilters(
|
|
||||||
val fileTypes: Set<FileType> = emptySet(),
|
|
||||||
val sourceFolders: Set<String> = emptySet(),
|
|
||||||
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
|
|
||||||
val tagIds: Set<String> = emptySet()
|
|
||||||
) {
|
|
||||||
val isActive: Boolean
|
|
||||||
get() = fileTypes.isNotEmpty() ||
|
|
||||||
sourceFolders.isNotEmpty() ||
|
|
||||||
readStatus != ReadStatusFilter.ALL ||
|
|
||||||
tagIds.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
|
|
@ -133,6 +134,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
|
@ -148,7 +150,6 @@ import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -163,6 +164,7 @@ private fun getBookCountString(count: Int): String {
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel,
|
||||||
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
val compStart = remember { System.currentTimeMillis() }
|
val compStart = remember { System.currentTimeMillis() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -170,14 +172,13 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
|
val selectedItems = uiState.contextualActionItems
|
||||||
val selectedItems = screenModel.selectedItems
|
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||||
val isContextualModeActive = screenModel.isContextualModeActive
|
val selectedShelves = uiState.contextualActionShelfIds
|
||||||
val selectedShelves = screenModel.selectedShelves
|
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||||
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
|
val sortOrder = uiState.sortOrder
|
||||||
val sortOrder = screenModel.sortOrder
|
val shelves = uiState.shelves
|
||||||
val shelves = screenModel.shelves
|
val rawLibraryFiles = uiState.rawLibraryFiles
|
||||||
val rawLibraryFiles = screenModel.rawLibraryFiles
|
|
||||||
val tabTitles = remember {
|
val tabTitles = remember {
|
||||||
buildList {
|
buildList {
|
||||||
add(context.getString(R.string.tab_all_books))
|
add(context.getString(R.string.tab_all_books))
|
||||||
|
|
@ -193,13 +194,13 @@ fun LibraryScreen(
|
||||||
pageCount = { tabTitles.size }
|
pageCount = { tabTitles.size }
|
||||||
)
|
)
|
||||||
|
|
||||||
val containsFolderItems = screenModel.containsFolderItemsInSelection
|
val containsFolderItems = selectedItems.any { it.sourceFolderUri != null }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showFilterSheet by remember { mutableStateOf(false) }
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isSearchActive = screenModel.isSearchActive
|
val isSearchActive = uiState.isSearchActive
|
||||||
val searchQuery = screenModel.searchQuery
|
val searchQuery = uiState.searchQuery
|
||||||
|
|
||||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
|
|
@ -341,7 +342,8 @@ fun LibraryScreen(
|
||||||
catalogId = catalog?.id
|
catalogId = catalog?.id
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog
|
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
|
||||||
|
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -394,8 +396,14 @@ fun LibraryScreen(
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
},
|
},
|
||||||
onUpdateName = { newName ->
|
onSaveMetadata = { metadata ->
|
||||||
viewModel.updateCustomName(item.bookId, newName)
|
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||||
|
},
|
||||||
|
onSaveDisplayName = { name ->
|
||||||
|
viewModel.updateCustomName(item.bookId, name)
|
||||||
|
},
|
||||||
|
onRestoreMetadata = {
|
||||||
|
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||||
},
|
},
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
)
|
)
|
||||||
|
|
@ -516,7 +524,9 @@ fun ShelfScreen(
|
||||||
FileInfoDialog(
|
FileInfoDialog(
|
||||||
item = item,
|
item = item,
|
||||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||||
onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) },
|
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||||
|
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||||
|
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
||||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -577,6 +587,7 @@ fun LibraryScreenContent(
|
||||||
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
onOpdsBookDownloaded: (Uri, String) -> Unit,
|
||||||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||||
onDeleteCatalogStreams: (String) -> Unit,
|
onDeleteCatalogStreams: (String) -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
||||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||||
|
|
@ -711,6 +722,9 @@ fun LibraryScreenContent(
|
||||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onSettingsClick) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
|
|
@ -1442,8 +1456,6 @@ private fun AddBooksModeScreen(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShelfCover(shelf: Shelf) {
|
private fun ShelfCover(shelf: Shelf) {
|
||||||
val context = LocalContext.current
|
|
||||||
val placeholder = R.drawable.epub_placeholder
|
|
||||||
val booksForCovers = shelf.books.take(4).reversed()
|
val booksForCovers = shelf.books.take(4).reversed()
|
||||||
val coverWidth = 52.dp
|
val coverWidth = 52.dp
|
||||||
val coverHeight = 75.dp
|
val coverHeight = 75.dp
|
||||||
|
|
@ -1457,22 +1469,24 @@ private fun ShelfCover(shelf: Shelf) {
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
if (booksForCovers.size <= 1) {
|
if (booksForCovers.size <= 1) {
|
||||||
val imageModel = remember(shelf.topBook?.coverImagePath) {
|
val topBook = shelf.topBook
|
||||||
shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder
|
if (topBook != null) {
|
||||||
|
ThemedBookCover(
|
||||||
|
item = topBook,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = coverWidth, height = coverHeight)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyShelfCover(
|
||||||
|
shelfName = shelf.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = coverWidth, height = coverHeight)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(context)
|
|
||||||
.data(imageModel)
|
|
||||||
.error(placeholder)
|
|
||||||
.fallback(placeholder)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = coverWidth, height = coverHeight)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -1480,9 +1494,6 @@ private fun ShelfCover(shelf: Shelf) {
|
||||||
.height(coverHeight)
|
.height(coverHeight)
|
||||||
) {
|
) {
|
||||||
booksForCovers.forEachIndexed { index, book ->
|
booksForCovers.forEachIndexed { index, book ->
|
||||||
val imageModel = remember(book.coverImagePath) {
|
|
||||||
book.coverImagePath?.let { File(it) } ?: placeholder
|
|
||||||
}
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = MaterialTheme.shapes.small,
|
shape = MaterialTheme.shapes.small,
|
||||||
shadowElevation = 4.dp,
|
shadowElevation = 4.dp,
|
||||||
|
|
@ -1491,13 +1502,8 @@ private fun ShelfCover(shelf: Shelf) {
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.offset(x = -horizontalOffset * index)
|
.offset(x = -horizontalOffset * index)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
ThemedBookCover(
|
||||||
model = ImageRequest.Builder(context)
|
item = book,
|
||||||
.data(imageModel)
|
|
||||||
.error(placeholder)
|
|
||||||
.fallback(placeholder)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
|
@ -1508,6 +1514,36 @@ private fun ShelfCover(shelf: Shelf) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyShelfCover(
|
||||||
|
shelfName: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShelfListItem(
|
private fun ShelfListItem(
|
||||||
|
|
@ -1596,15 +1632,6 @@ private fun LibraryListItem(
|
||||||
onItemLongClick: () -> Unit,
|
onItemLongClick: () -> Unit,
|
||||||
isDownloading: Boolean,
|
isDownloading: Boolean,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val placeholder = when (item.type) {
|
|
||||||
FileType.PDF -> R.drawable.pdf_placeholder
|
|
||||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
|
||||||
}
|
|
||||||
val imageModel = remember(item.coverImagePath) {
|
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
|
|
@ -1641,13 +1668,8 @@ private fun LibraryListItem(
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
ThemedBookCover(
|
||||||
model = ImageRequest.Builder(context)
|
item = item,
|
||||||
.data(imageModel)
|
|
||||||
.error(placeholder)
|
|
||||||
.fallback(placeholder)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = item.displayName,
|
contentDescription = item.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|
@ -2148,7 +2170,7 @@ private fun EditFolderFiltersDialog(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
FileType.entries.forEach { type ->
|
ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
|
||||||
val isSelected = type in selectedTypes
|
val isSelected = type in selectedTypes
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
|
|
@ -2226,7 +2248,7 @@ fun LibraryFilterSheet(
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
FileType.entries.forEach { type ->
|
ANDROID_READABLE_FILE_TYPES.forEach { type ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = type in currentFilters.fileTypes,
|
selected = type in currentFilters.fileTypes,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import com.aryan.reader.data.BookTagCrossRef
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.ShelfEntity
|
import com.aryan.reader.data.ShelfEntity
|
||||||
import com.aryan.reader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
import com.aryan.reader.shared.SmartCollectionEngine
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
|
||||||
|
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
|
||||||
|
import com.aryan.reader.shared.sortBooks as sharedSortBooks
|
||||||
|
|
||||||
fun interface FolderPathResolver {
|
fun interface FolderPathResolver {
|
||||||
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||||
|
|
@ -32,17 +35,28 @@ class LibraryStateProjector(
|
||||||
fun project(input: LibraryProjectionInput): ReaderScreenState {
|
fun project(input: LibraryProjectionInput): ReaderScreenState {
|
||||||
val start = ReaderPerfLog.nowNanos()
|
val start = ReaderPerfLog.nowNanos()
|
||||||
val internalState = input.state
|
val internalState = input.state
|
||||||
|
val bridgeContext = AndroidSharedStateBridge.prepareLibraryProjection(input, folderPathResolver)
|
||||||
val cacheKey = ProjectionCacheKey(
|
val cacheKey = ProjectionCacheKey(
|
||||||
recentFilesFromDb = input.recentFilesFromDb,
|
recentFilesFromDb = input.recentFilesFromDb,
|
||||||
dbShelves = input.dbShelves,
|
dbShelves = input.dbShelves,
|
||||||
shelfRefs = input.shelfRefs,
|
shelfRefs = input.shelfRefs,
|
||||||
dbTags = input.dbTags,
|
dbTags = input.dbTags,
|
||||||
tagRefs = input.tagRefs,
|
tagRefs = input.tagRefs,
|
||||||
folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) },
|
folderKeys = bridgeContext.folderKeys,
|
||||||
sortOrder = internalState.sortOrder,
|
sortOrder = internalState.sortOrder,
|
||||||
searchQuery = internalState.searchQuery,
|
searchQuery = internalState.searchQuery,
|
||||||
libraryFilters = internalState.libraryFilters,
|
libraryFilters = internalState.libraryFilters,
|
||||||
recentFilesLimit = internalState.recentFilesLimit
|
recentFilesLimit = internalState.recentFilesLimit,
|
||||||
|
openTabIds = internalState.openTabIds,
|
||||||
|
activeTabBookId = internalState.activeTabBookId,
|
||||||
|
selectedBookIds = internalState.contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
|
||||||
|
selectedShelfIds = internalState.contextualActionShelfIds,
|
||||||
|
viewingShelfId = internalState.viewingShelfId,
|
||||||
|
isAddingBooksToShelf = internalState.isAddingBooksToShelf,
|
||||||
|
addBooksSource = internalState.addBooksSource,
|
||||||
|
booksSelectedForAdding = internalState.booksSelectedForAdding,
|
||||||
|
pinnedHomeBookIds = internalState.pinnedHomeBookIds,
|
||||||
|
pinnedLibraryBookIds = internalState.pinnedLibraryBookIds
|
||||||
)
|
)
|
||||||
|
|
||||||
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
|
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
|
||||||
|
|
@ -50,71 +64,26 @@ class LibraryStateProjector(
|
||||||
val elapsed = ReaderPerfLog.elapsedMs(start)
|
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||||
if (elapsed >= 8L) {
|
if (elapsed >= 8L) {
|
||||||
ReaderPerfLog.d(
|
ReaderPerfLog.d(
|
||||||
"LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}"
|
"LibraryProject cache-hit took ${elapsed}ms books=${cache.androidBooksById.size} shelves=${cache.projected.shelves.size}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
val tagsById = input.dbTags.associateBy { it.id }
|
val projected = AndroidSharedStateBridge.projectLibrary(bridgeContext)
|
||||||
val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
|
||||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
|
||||||
}
|
|
||||||
|
|
||||||
val allLibraryFiles = input.recentFilesFromDb
|
|
||||||
.filterNot { it.bookId.endsWith("_reflow") }
|
|
||||||
.map { item ->
|
|
||||||
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
|
|
||||||
}
|
|
||||||
val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId }
|
|
||||||
|
|
||||||
val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery)
|
|
||||||
val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters)
|
|
||||||
val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
|
||||||
libraryFiltered
|
|
||||||
} else {
|
|
||||||
sortFiles(libraryFiltered, internalState.sortOrder)
|
|
||||||
}
|
|
||||||
val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
|
|
||||||
val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
|
||||||
allLibraryFiles
|
|
||||||
.asSequence()
|
|
||||||
.filter { it.isRecent }
|
|
||||||
.take(recentLimit)
|
|
||||||
.toList()
|
|
||||||
} else {
|
|
||||||
sortFiles(
|
|
||||||
allLibraryFiles.filter { it.isRecent },
|
|
||||||
internalState.sortOrder
|
|
||||||
).take(recentLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
val shelfProjection = buildShelves(
|
|
||||||
allLibraryFiles = allLibraryFiles,
|
|
||||||
dbShelves = input.dbShelves,
|
|
||||||
shelfRefs = input.shelfRefs,
|
|
||||||
dbTags = input.dbTags,
|
|
||||||
sortOrder = internalState.sortOrder,
|
|
||||||
syncedFolders = internalState.syncedFolders
|
|
||||||
)
|
|
||||||
|
|
||||||
val cache = CachedProjection(
|
val cache = CachedProjection(
|
||||||
key = cacheKey,
|
key = cacheKey,
|
||||||
allLibraryFiles = allLibraryFiles,
|
projected = projected,
|
||||||
allLibraryFilesById = allLibraryFilesById,
|
androidBooksById = bridgeContext.androidBooksById,
|
||||||
sortedLibraryFiles = sortedLibraryFiles,
|
tagEntitiesById = bridgeContext.tagEntitiesById
|
||||||
visibleRecentFiles = visibleRecentFiles,
|
|
||||||
shelfProjection = shelfProjection,
|
|
||||||
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
|
|
||||||
dbTags = input.dbTags
|
|
||||||
)
|
)
|
||||||
cachedProjection = cache
|
cachedProjection = cache
|
||||||
|
|
||||||
val elapsed = ReaderPerfLog.elapsedMs(start)
|
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||||
if (elapsed >= 16L || allLibraryFiles.size >= 500) {
|
if (elapsed >= 16L || bridgeContext.androidBooksById.size >= 500) {
|
||||||
ReaderPerfLog.d(
|
ReaderPerfLog.d(
|
||||||
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
|
"LibraryProject shared recompute took ${elapsed}ms books=${bridgeContext.androidBooksById.size} " +
|
||||||
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
|
"visible=${projected.libraryBooks.size} shelves=${projected.shelves.size} " +
|
||||||
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
|
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -126,310 +95,63 @@ class LibraryStateProjector(
|
||||||
internalState: ReaderScreenState,
|
internalState: ReaderScreenState,
|
||||||
cache: CachedProjection
|
cache: CachedProjection
|
||||||
): ReaderScreenState {
|
): ReaderScreenState {
|
||||||
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds }
|
return AndroidSharedStateBridge.toAndroidState(
|
||||||
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds }
|
base = internalState,
|
||||||
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
|
sharedState = cache.projected,
|
||||||
val currentShelfBookIds = cache.shelfProjection.shelves
|
androidBooksById = cache.androidBooksById,
|
||||||
.find { it.id == viewingShelfId }
|
tagEntitiesById = cache.tagEntitiesById
|
||||||
?.books
|
|
||||||
?.mapTo(mutableSetOf()) { it.bookId }
|
|
||||||
?: emptySet()
|
|
||||||
when (internalState.addBooksSource) {
|
|
||||||
AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks
|
|
||||||
AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
return internalState.copy(
|
|
||||||
recentFiles = cache.visibleRecentFiles,
|
|
||||||
allRecentFiles = cache.sortedLibraryFiles,
|
|
||||||
rawLibraryFiles = cache.allLibraryFiles,
|
|
||||||
viewingShelfId = viewingShelfId,
|
|
||||||
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
|
|
||||||
contextualActionShelfIds = selectedShelfIds,
|
|
||||||
contextualActionItems = internalState.contextualActionItems
|
|
||||||
.mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] }
|
|
||||||
.toSet(),
|
|
||||||
shelves = cache.shelfProjection.shelves,
|
|
||||||
openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] },
|
|
||||||
booksAvailableForAdding = booksAvailableForAdding,
|
|
||||||
allTags = cache.dbTags
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildShelves(
|
|
||||||
allLibraryFiles: List<RecentFileItem>,
|
|
||||||
dbShelves: List<ShelfEntity>,
|
|
||||||
shelfRefs: List<BookShelfCrossRef>,
|
|
||||||
dbTags: List<TagEntity>,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
syncedFolders: List<SyncedFolder>
|
|
||||||
): ShelfProjection {
|
|
||||||
val allShelves = mutableListOf<Shelf>()
|
|
||||||
val shelvedBookIds = mutableSetOf<String>()
|
|
||||||
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
|
|
||||||
val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
|
|
||||||
val taggedBookIdsByTagId = mutableMapOf<String, MutableList<String>>()
|
|
||||||
|
|
||||||
allLibraryFiles.forEach { item ->
|
|
||||||
item.tags.forEach { tag ->
|
|
||||||
taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbShelves.forEach { shelfEntity ->
|
|
||||||
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
|
|
||||||
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
|
|
||||||
if (rules != null) {
|
|
||||||
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) }
|
|
||||||
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
|
|
||||||
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty()
|
|
||||||
.sortedBy { it.addedAt }
|
|
||||||
.map { it.bookId }
|
|
||||||
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
|
|
||||||
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder)))
|
|
||||||
shelvedBookIds.addAll(bookIdsInShelf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val tagShelves = dbTags.mapNotNull { tag ->
|
|
||||||
val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] }
|
|
||||||
if (taggedBooks.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allShelves.addAll(tagShelves)
|
|
||||||
|
|
||||||
val seriesShelves = allLibraryFiles
|
|
||||||
.filter { !it.seriesName.isNullOrBlank() }
|
|
||||||
.groupBy { it.seriesName!! }
|
|
||||||
.filter { it.value.size >= 2 }
|
|
||||||
.map { (series, books) ->
|
|
||||||
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
|
|
||||||
shelvedBookIds.addAll(books.map { it.bookId })
|
|
||||||
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
|
|
||||||
}
|
|
||||||
allShelves.addAll(seriesShelves)
|
|
||||||
|
|
||||||
val folderShelves = buildFolderShelves(
|
|
||||||
allLibraryFiles = allLibraryFiles,
|
|
||||||
syncedFolders = syncedFolders,
|
|
||||||
sortOrder = sortOrder
|
|
||||||
).also { shelves ->
|
|
||||||
shelves.forEach { shelf ->
|
|
||||||
shelvedBookIds.addAll(shelf.books.map { it.bookId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allShelves.addAll(folderShelves)
|
|
||||||
|
|
||||||
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
|
|
||||||
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder)))
|
|
||||||
|
|
||||||
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
|
|
||||||
return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildFolderShelves(
|
|
||||||
allLibraryFiles: List<RecentFileItem>,
|
|
||||||
syncedFolders: List<SyncedFolder>,
|
|
||||||
sortOrder: SortOrder
|
|
||||||
): List<Shelf> {
|
|
||||||
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
|
|
||||||
val folderSegmentsByBookId = allLibraryFiles
|
|
||||||
.asSequence()
|
|
||||||
.filter { it.sourceFolderUri != null }
|
|
||||||
.associate { it.bookId to folderPathResolver.relativeFolderSegments(it) }
|
|
||||||
|
|
||||||
return allLibraryFiles
|
|
||||||
.filter { it.sourceFolderUri != null }
|
|
||||||
.groupBy { it.sourceFolderUri!! }
|
|
||||||
.flatMap { (folderUri, books) ->
|
|
||||||
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
|
|
||||||
val rootShelfId = "folder_$folderUri"
|
|
||||||
val rootAccumulator = FolderShelfAccumulator(
|
|
||||||
id = rootShelfId,
|
|
||||||
name = rootName,
|
|
||||||
depth = 0,
|
|
||||||
parentShelfId = null,
|
|
||||||
sortPath = ""
|
|
||||||
)
|
|
||||||
val rootShelf = Shelf(
|
|
||||||
id = rootShelfId,
|
|
||||||
name = rootName,
|
|
||||||
type = ShelfType.FOLDER,
|
|
||||||
books = sortFiles(books, sortOrder),
|
|
||||||
directBooks = emptyList(),
|
|
||||||
childShelfIds = emptyList(),
|
|
||||||
depth = 0,
|
|
||||||
sortKey = "folder:${rootName.lowercase()}:"
|
|
||||||
)
|
|
||||||
|
|
||||||
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
|
|
||||||
val nestedShelvesById = mutableMapOf<String, FolderShelfAccumulator>()
|
|
||||||
books.forEach { book ->
|
|
||||||
rootAccumulator.books.add(book)
|
|
||||||
val segments = folderSegmentsByBookId[book.bookId].orEmpty()
|
|
||||||
if (segments.isEmpty()) {
|
|
||||||
rootAccumulator.directBooks.add(book)
|
|
||||||
}
|
|
||||||
var currentPath = ""
|
|
||||||
var parentShelfId = rootShelfId
|
|
||||||
segments.forEachIndexed { index, segment ->
|
|
||||||
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
|
|
||||||
val shelfId = "folder_$folderUri::$currentPath"
|
|
||||||
val accumulator = nestedShelves.getOrPut(currentPath) {
|
|
||||||
val newShelf = FolderShelfAccumulator(
|
|
||||||
id = shelfId,
|
|
||||||
name = segment,
|
|
||||||
depth = index + 1,
|
|
||||||
parentShelfId = parentShelfId,
|
|
||||||
sortPath = currentPath.lowercase()
|
|
||||||
)
|
|
||||||
if (parentShelfId == rootShelfId) {
|
|
||||||
rootAccumulator.childShelfIds.add(shelfId)
|
|
||||||
} else {
|
|
||||||
nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId)
|
|
||||||
}
|
|
||||||
nestedShelvesById[shelfId] = newShelf
|
|
||||||
newShelf
|
|
||||||
}
|
|
||||||
accumulator.books.add(book)
|
|
||||||
if (index == segments.lastIndex) {
|
|
||||||
accumulator.directBooks.add(book)
|
|
||||||
}
|
|
||||||
parentShelfId = shelfId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sortedNestedShelves = nestedShelves
|
|
||||||
.values
|
|
||||||
.sortedBy { it.sortPath }
|
|
||||||
.map { shelf ->
|
|
||||||
Shelf(
|
|
||||||
id = shelf.id,
|
|
||||||
name = shelf.name,
|
|
||||||
type = ShelfType.FOLDER,
|
|
||||||
books = sortFiles(shelf.books, sortOrder),
|
|
||||||
directBooks = sortFiles(shelf.directBooks, sortOrder),
|
|
||||||
parentShelfId = shelf.parentShelfId,
|
|
||||||
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
|
|
||||||
depth = shelf.depth,
|
|
||||||
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
listOf(
|
|
||||||
rootShelf.copy(
|
|
||||||
directBooks = sortFiles(rootAccumulator.directBooks, sortOrder),
|
|
||||||
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
|
|
||||||
)
|
|
||||||
) + sortedNestedShelves
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class FolderShelfAccumulator(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val depth: Int,
|
|
||||||
val parentShelfId: String?,
|
|
||||||
val sortPath: String,
|
|
||||||
val books: MutableList<RecentFileItem> = mutableListOf(),
|
|
||||||
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
|
|
||||||
val childShelfIds: MutableList<String> = mutableListOf()
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class ShelfProjection(
|
|
||||||
val shelves: List<Shelf>,
|
|
||||||
val unshelvedBooks: List<RecentFileItem>
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class ProjectionCacheKey(
|
private data class ProjectionCacheKey(
|
||||||
val recentFilesFromDb: List<RecentFileItem>,
|
val recentFilesFromDb: List<RecentFileItem>,
|
||||||
val dbShelves: List<ShelfEntity>,
|
val dbShelves: List<ShelfEntity>,
|
||||||
val shelfRefs: List<BookShelfCrossRef>,
|
val shelfRefs: List<BookShelfCrossRef>,
|
||||||
val dbTags: List<TagEntity>,
|
val dbTags: List<TagEntity>,
|
||||||
val tagRefs: List<BookTagCrossRef>,
|
val tagRefs: List<BookTagCrossRef>,
|
||||||
val folderKeys: List<SyncedFolderProjectionKey>,
|
val folderKeys: List<AndroidSharedFolderProjectionKey>,
|
||||||
val sortOrder: SortOrder,
|
val sortOrder: SortOrder,
|
||||||
val searchQuery: String,
|
val searchQuery: String,
|
||||||
val libraryFilters: LibraryFilters,
|
val libraryFilters: LibraryFilters,
|
||||||
val recentFilesLimit: Int
|
val recentFilesLimit: Int,
|
||||||
)
|
val openTabIds: List<String>,
|
||||||
|
val activeTabBookId: String?,
|
||||||
private data class SyncedFolderProjectionKey(
|
val selectedBookIds: Set<String>,
|
||||||
val uriString: String,
|
val selectedShelfIds: Set<String>,
|
||||||
val name: String
|
val viewingShelfId: String?,
|
||||||
|
val isAddingBooksToShelf: Boolean,
|
||||||
|
val addBooksSource: AddBooksSource,
|
||||||
|
val booksSelectedForAdding: Set<String>,
|
||||||
|
val pinnedHomeBookIds: Set<String>,
|
||||||
|
val pinnedLibraryBookIds: Set<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class CachedProjection(
|
private data class CachedProjection(
|
||||||
val key: ProjectionCacheKey,
|
val key: ProjectionCacheKey,
|
||||||
val allLibraryFiles: List<RecentFileItem>,
|
val projected: SharedReaderScreenState,
|
||||||
val allLibraryFilesById: Map<String, RecentFileItem>,
|
val androidBooksById: Map<String, RecentFileItem>,
|
||||||
val sortedLibraryFiles: List<RecentFileItem>,
|
val tagEntitiesById: Map<String, TagEntity>
|
||||||
val visibleRecentFiles: List<RecentFileItem>,
|
|
||||||
val shelfProjection: ShelfProjection,
|
|
||||||
val validShelfIds: Set<String>,
|
|
||||||
val dbTags: List<TagEntity>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
|
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
|
||||||
val query = searchQuery.trim()
|
return files.mapSharedResults(sharedFilterBySearch(files.map { it.toSharedProjectionBookItem() }, searchQuery))
|
||||||
return if (query.isBlank()) {
|
|
||||||
files
|
|
||||||
} else {
|
|
||||||
files.filter { item ->
|
|
||||||
item.displayName.contains(query, ignoreCase = true) ||
|
|
||||||
item.title?.contains(query, ignoreCase = true) == true ||
|
|
||||||
item.author?.contains(query, ignoreCase = true) == true ||
|
|
||||||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
|
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
|
||||||
return files.filter { item ->
|
return files.mapSharedResults(
|
||||||
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
|
sharedApplyLibraryFilters(
|
||||||
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
|
books = files.map { it.toSharedProjectionBookItem() },
|
||||||
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") &&
|
filters = filters.toSharedLibraryFilters()
|
||||||
item.sourceFolderUri == null &&
|
)
|
||||||
item.uriString?.startsWith("opds-pse") != true
|
)
|
||||||
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
|
|
||||||
matchesInApp || matchesSynced
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
val progress = item.progressPercentage ?: 0f
|
|
||||||
val matchStatus = when (filters.readStatus) {
|
|
||||||
ReadStatusFilter.ALL -> true
|
|
||||||
ReadStatusFilter.UNREAD -> progress == 0f
|
|
||||||
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
|
||||||
ReadStatusFilter.COMPLETED -> progress >= 100f
|
|
||||||
}
|
|
||||||
val matchTags = if (filters.tagIds.isNotEmpty()) {
|
|
||||||
item.tags.any { it.id in filters.tagIds }
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
matchType && matchFolder && matchStatus && matchTags
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
|
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
|
||||||
return when (sortOrder) {
|
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder()))
|
||||||
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
|
}
|
||||||
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
|
||||||
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
|
||||||
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
val byId = associateBy { it.bookId }
|
||||||
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
return sharedBooks.mapNotNull { byId[it.id] }
|
||||||
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
|
|
||||||
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,9 +121,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
WebView.setWebContentsDebuggingEnabled(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,10 @@ fun MainScreen(
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
1 -> LibraryScreen(viewModel = viewModel)
|
1 -> LibraryScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,10 @@ import android.provider.OpenableColumns
|
||||||
import android.util.Xml
|
import android.util.Xml
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.WorkManager
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
import io.legere.pdfiumandroid.PdfiumCore
|
import io.legere.pdfiumandroid.PdfiumCore
|
||||||
|
|
@ -28,7 +31,17 @@ class MetadataExtractionWorker(
|
||||||
const val WORK_NAME = "MetadataExtractionWorker"
|
const val WORK_NAME = "MetadataExtractionWorker"
|
||||||
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
|
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
|
||||||
private const val METADATA_DB_BATCH_SIZE = 100
|
private const val METADATA_DB_BATCH_SIZE = 100
|
||||||
|
private const val METADATA_WORKER_BOOK_BATCH_SIZE = 300
|
||||||
private const val METADATA_PROGRESS_LOG_EVERY = 250
|
private const val METADATA_PROGRESS_LOG_EVERY = 250
|
||||||
|
private val TEXT_METADATA_TYPES = setOf(
|
||||||
|
FileType.PDF,
|
||||||
|
FileType.EPUB,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT,
|
||||||
|
FileType.DOCX
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -45,20 +58,25 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
|
||||||
|
sourceFolderUri = sourceFolderUri,
|
||||||
|
limit = METADATA_WORKER_BOOK_BATCH_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
if (filesToProcess.isEmpty()) {
|
if (filesToProcess.isEmpty()) {
|
||||||
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
ReaderPerfLog.i(
|
ReaderPerfLog.i(
|
||||||
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
|
"MetadataWorker start mode=metadata books=${filesToProcess.size} " +
|
||||||
|
"batchLimit=$METADATA_WORKER_BOOK_BATCH_SIZE folder=${sourceFolderUri ?: "ALL"}"
|
||||||
)
|
)
|
||||||
|
|
||||||
val pendingUpdates = mutableListOf<RecentFileItem>()
|
val pendingUpdates = mutableListOf<RecentFileItem>()
|
||||||
var processed = 0
|
var processed = 0
|
||||||
var updated = 0
|
var updated = 0
|
||||||
|
var coversUpdated = 0
|
||||||
var failed = 0
|
var failed = 0
|
||||||
|
|
||||||
suspend fun flushUpdates() {
|
suspend fun flushUpdates() {
|
||||||
|
|
@ -76,36 +94,80 @@ class MetadataExtractionWorker(
|
||||||
|
|
||||||
if (item.sourceFolderUri == null) return@forEach
|
if (item.sourceFolderUri == null) return@forEach
|
||||||
|
|
||||||
|
var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed
|
||||||
|
var needsEmbeddedCover = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = item.uriString?.toUri() ?: return@forEach
|
val uri = item.uriString?.toUri() ?: return@forEach
|
||||||
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
|
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
|
||||||
|
val existingCoverIsAvailable = item.coverImagePath?.let { File(it).isFile } == true
|
||||||
|
needsEmbeddedCover = EmbeddedEbookMetadataExtractor.canExtractEmbeddedCover(item.type) &&
|
||||||
|
!item.folderCoverMetadataParsed &&
|
||||||
|
!existingCoverIsAvailable
|
||||||
|
|
||||||
val metadata = when (item.type) {
|
val metadata = when (item.type) {
|
||||||
FileType.EPUB -> parseEpubTextMetadata(uri)
|
FileType.EPUB,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2 -> {
|
||||||
|
if (needsTextMetadata || needsEmbeddedCover) {
|
||||||
|
EmbeddedEbookMetadataExtractor.extract(
|
||||||
|
type = item.type,
|
||||||
|
displayName = item.displayName,
|
||||||
|
openStream = { appContext.contentResolver.openInputStream(uri) },
|
||||||
|
extractCover = needsEmbeddedCover
|
||||||
|
).toTextMetadata()
|
||||||
|
} else {
|
||||||
|
TextMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
FileType.PDF -> parsePdfTextMetadata(uri)
|
FileType.PDF -> parsePdfTextMetadata(uri)
|
||||||
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
|
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
|
||||||
FileType.FODT -> parseFlatXmlTextMetadata(uri)
|
FileType.FODT -> parseFlatXmlTextMetadata(uri)
|
||||||
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
||||||
|
FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
||||||
else -> TextMetadata()
|
else -> TextMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
val title = sanitizeTitle(metadata.title)
|
val title = sanitizeTitle(metadata.title)
|
||||||
val author = sanitizeAuthor(metadata.author)
|
val author = sanitizeAuthor(metadata.author)
|
||||||
|
val description = metadata.description?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val seriesName = metadata.seriesName?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val seriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
|
||||||
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
|
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
|
||||||
val titleChanged = title != null && title != item.title
|
val titleChanged = title != null && title != item.title
|
||||||
val authorChanged = author != null && author != item.author
|
val authorChanged = author != null && author != item.author
|
||||||
|
val descriptionChanged = description != null && description != item.description
|
||||||
|
val seriesChanged = seriesName != null && seriesName != item.seriesName
|
||||||
|
val seriesIndexChanged = seriesIndex != null && seriesIndex != item.seriesIndex
|
||||||
|
val coverPath = if (needsEmbeddedCover) {
|
||||||
|
metadata.cover?.let { cover ->
|
||||||
|
recentFilesRepository.saveEmbeddedCoverToCache(cover.bytes, uri, cover.extension)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val coverChanged = coverPath != null && coverPath != item.coverImagePath
|
||||||
|
val coverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
|
||||||
|
val textMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata
|
||||||
|
|
||||||
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
|
if (needsTextMetadata || needsEmbeddedCover || sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
|
||||||
pendingUpdates.add(
|
pendingUpdates.add(
|
||||||
item.copy(
|
item.copy(
|
||||||
|
coverImagePath = coverPath ?: item.coverImagePath,
|
||||||
title = title ?: item.title ?: item.displayName,
|
title = title ?: item.title ?: item.displayName,
|
||||||
author = author ?: item.author,
|
author = author ?: item.author,
|
||||||
|
description = description ?: item.description,
|
||||||
|
seriesName = seriesName ?: item.seriesName,
|
||||||
|
seriesIndex = seriesIndex ?: item.seriesIndex,
|
||||||
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
|
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
|
||||||
folderTextMetadataParsed = true
|
folderTextMetadataParsed = textMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = coverMetadataParsed
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (sizeChanged || titleChanged || authorChanged) {
|
if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
|
||||||
updated++
|
updated++
|
||||||
}
|
}
|
||||||
|
if (coverChanged) coversUpdated++
|
||||||
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||||
flushUpdates()
|
flushUpdates()
|
||||||
}
|
}
|
||||||
|
|
@ -114,29 +176,64 @@ class MetadataExtractionWorker(
|
||||||
processed++
|
processed++
|
||||||
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
|
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
|
||||||
ReaderPerfLog.d(
|
ReaderPerfLog.d(
|
||||||
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
|
"MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
failed++
|
failed++
|
||||||
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
|
Timber.tag("MetadataWorker").e(e, "Failed metadata extraction for ${item.displayName}")
|
||||||
|
if (needsTextMetadata || needsEmbeddedCover) {
|
||||||
|
pendingUpdates.add(
|
||||||
|
item.copy(
|
||||||
|
folderTextMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata,
|
||||||
|
folderCoverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||||
|
flushUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flushUpdates()
|
flushUpdates()
|
||||||
|
|
||||||
|
val nextBatchEnqueued = !isStopped &&
|
||||||
|
filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE &&
|
||||||
|
recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||||
|
if (nextBatchEnqueued) {
|
||||||
|
enqueueNextBatch(sourceFolderUri)
|
||||||
|
}
|
||||||
|
|
||||||
ReaderPerfLog.i(
|
ReaderPerfLog.i(
|
||||||
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
|
"MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " +
|
||||||
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
|
"nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
|
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
|
||||||
return@withContext Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enqueueNextBatch(sourceFolderUri: String?) {
|
||||||
|
val data = androidx.work.Data.Builder().apply {
|
||||||
|
if (!sourceFolderUri.isNullOrBlank()) {
|
||||||
|
putString(KEY_SOURCE_FOLDER_URI, sourceFolderUri)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
val request = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||||
|
.setInputData(data)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
ReaderPerfLog.d("MetadataWorker enqueued next metadata batch folder=${sourceFolderUri ?: "ALL"}")
|
||||||
|
}
|
||||||
|
|
||||||
private fun queryFileSize(uri: android.net.Uri): Long {
|
private fun queryFileSize(uri: android.net.Uri): Long {
|
||||||
return try {
|
return try {
|
||||||
if (uri.scheme == "file") {
|
if (uri.scheme == "file") {
|
||||||
|
|
@ -157,30 +254,6 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
|
|
||||||
val opfEntries = linkedMapOf<String, String>()
|
|
||||||
var containerXml: String? = null
|
|
||||||
|
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
ZipInputStream(input.buffered()).use { zip ->
|
|
||||||
while (true) {
|
|
||||||
val entry = zip.nextEntry ?: break
|
|
||||||
if (entry.isDirectory) continue
|
|
||||||
val name = entry.name
|
|
||||||
when {
|
|
||||||
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
|
|
||||||
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
|
|
||||||
}
|
|
||||||
zip.closeEntry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
|
|
||||||
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
|
|
||||||
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
|
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
ZipInputStream(input.buffered()).use { zip ->
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
|
@ -222,21 +295,6 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseEpubRootfilePath(containerXml: String): String? {
|
|
||||||
val parser = Xml.newPullParser()
|
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
|
||||||
parser.setInput(containerXml.reader())
|
|
||||||
|
|
||||||
var event = parser.eventType
|
|
||||||
while (event != XmlPullParser.END_DOCUMENT) {
|
|
||||||
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
|
|
||||||
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
|
|
||||||
}
|
|
||||||
event = parser.next()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseXmlTextMetadata(xml: String): TextMetadata {
|
private fun parseXmlTextMetadata(xml: String): TextMetadata {
|
||||||
val parser = Xml.newPullParser()
|
val parser = Xml.newPullParser()
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||||
|
|
@ -286,8 +344,23 @@ class MetadataExtractionWorker(
|
||||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun EmbeddedEbookMetadata.toTextMetadata(): TextMetadata {
|
||||||
|
return TextMetadata(
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private data class TextMetadata(
|
private data class TextMetadata(
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val author: String? = null
|
val author: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val cover: EmbeddedEbookCover? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package com.aryan.reader
|
|
||||||
|
|
||||||
import com.aryan.reader.data.RecentFileItem
|
|
||||||
|
|
||||||
data class HomeScreenModel(
|
|
||||||
val recentFiles: List<RecentFileItem>,
|
|
||||||
val openTabs: List<RecentFileItem>,
|
|
||||||
val selectedItems: Set<RecentFileItem>,
|
|
||||||
val isContextualModeActive: Boolean,
|
|
||||||
val deviceLimitState: DeviceLimitReachedState,
|
|
||||||
val isEmpty: Boolean,
|
|
||||||
val isLibraryEmpty: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel {
|
|
||||||
val homeRecentFiles = recentFiles
|
|
||||||
return HomeScreenModel(
|
|
||||||
recentFiles = homeRecentFiles,
|
|
||||||
openTabs = openTabs,
|
|
||||||
selectedItems = contextualActionItems,
|
|
||||||
isContextualModeActive = contextualActionItems.isNotEmpty(),
|
|
||||||
deviceLimitState = deviceLimitState,
|
|
||||||
isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
|
|
||||||
isLibraryEmpty = recentFiles.isEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LibraryScreenModel(
|
|
||||||
val selectedItems: Set<RecentFileItem>,
|
|
||||||
val isContextualModeActive: Boolean,
|
|
||||||
val selectedShelves: Set<String>,
|
|
||||||
val isShelfContextualModeActive: Boolean,
|
|
||||||
val sortOrder: SortOrder,
|
|
||||||
val shelves: List<Shelf>,
|
|
||||||
val rawLibraryFiles: List<RecentFileItem>,
|
|
||||||
val containsFolderItemsInSelection: Boolean,
|
|
||||||
val isSearchActive: Boolean,
|
|
||||||
val searchQuery: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel {
|
|
||||||
return LibraryScreenModel(
|
|
||||||
selectedItems = contextualActionItems,
|
|
||||||
isContextualModeActive = contextualActionItems.isNotEmpty(),
|
|
||||||
selectedShelves = contextualActionShelfIds,
|
|
||||||
isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(),
|
|
||||||
sortOrder = sortOrder,
|
|
||||||
shelves = shelves,
|
|
||||||
rawLibraryFiles = rawLibraryFiles,
|
|
||||||
containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null },
|
|
||||||
isSearchActive = isSearchActive,
|
|
||||||
searchQuery = searchQuery
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -274,7 +274,7 @@ private fun ProTierCard(
|
||||||
) {
|
) {
|
||||||
val productDetails = proUpgradeState.productDetails
|
val productDetails = proUpgradeState.productDetails
|
||||||
val billingClientReady = proUpgradeState.billingClientReady
|
val billingClientReady = proUpgradeState.billingClientReady
|
||||||
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase
|
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict
|
||||||
|
|
||||||
var originalFormattedPrice by remember { mutableStateOf("$9.99") }
|
var originalFormattedPrice by remember { mutableStateOf("$9.99") }
|
||||||
|
|
||||||
|
|
@ -467,23 +467,6 @@ private fun ProTierCard(
|
||||||
Text(stringResource(R.string.verifying_purchase))
|
Text(stringResource(R.string.verifying_purchase))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localPurchaseExistsForOtherAccount -> {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onShowExistingPurchaseDialog,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Info,
|
|
||||||
contentDescription = stringResource(R.string.info),
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
AutoSizeText(stringResource(R.string.existing_purchase_found))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
productDetails != null -> {
|
productDetails != null -> {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -538,6 +521,17 @@ private fun ProTierCard(
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
localPurchaseExistsForOtherAccount -> {
|
||||||
|
TextButton(onClick = onShowExistingPurchaseDialog) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Info,
|
||||||
|
contentDescription = stringResource(R.string.info),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
AutoSizeText(stringResource(R.string.existing_purchase_found))
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LegalText(prefixText = stringResource(R.string.legal_by_purchasing))
|
LegalText(prefixText = stringResource(R.string.legal_by_purchasing))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
object PurchaseAccountObfuscator {
|
||||||
|
fun obfuscatedAccountId(uid: String): String {
|
||||||
|
return "firebase_${sha256Base64Url(uid)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purchaseTokenHash(purchaseToken: String): String {
|
||||||
|
return "sha256_${sha256Base64Url(purchaseToken)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Base64Url(value: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.getUrlEncoder()
|
||||||
|
.withoutPadding()
|
||||||
|
.encodeToString(digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
private const val READER_PAGINATION_PREFS_NAME = "reader_prefs"
|
||||||
|
private const val PDF_RIGHT_TO_LEFT_PAGINATION_KEY = "pdf_right_to_left_pagination_enabled"
|
||||||
|
private const val EPUB_RIGHT_TO_LEFT_PAGINATION_KEY = "epub_right_to_left_pagination_enabled"
|
||||||
|
|
||||||
|
fun savePdfRightToLeftPagination(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPdfRightToLeftPagination(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEpubRightToLeftPagination(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadEpubRightToLeftPagination(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, false)
|
||||||
|
}
|
||||||
183
app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
Normal file
183
app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
private const val READER_SCREEN_ORIENTATION_PREFS_NAME = "epub_reader_settings"
|
||||||
|
private const val READER_SCREEN_ORIENTATION_KEY = "reader_screen_orientation_mode"
|
||||||
|
|
||||||
|
enum class ReaderScreenOrientationMode(
|
||||||
|
val id: Int,
|
||||||
|
val title: String
|
||||||
|
) {
|
||||||
|
FOLLOW_SYSTEM(0, "Follow system"),
|
||||||
|
PORTRAIT(1, "Portrait"),
|
||||||
|
LANDSCAPE(2, "Landscape")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveReaderScreenOrientationMode(context: Context, mode: ReaderScreenOrientationMode) {
|
||||||
|
val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putInt(READER_SCREEN_ORIENTATION_KEY, mode.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadReaderScreenOrientationMode(context: Context): ReaderScreenOrientationMode {
|
||||||
|
val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getInt(READER_SCREEN_ORIENTATION_KEY, ReaderScreenOrientationMode.FOLLOW_SYSTEM.id)
|
||||||
|
return ReaderScreenOrientationMode.entries.find { it.id == id } ?: ReaderScreenOrientationMode.FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ReaderScreenOrientationMode.toRequestedOrientation(): Int {
|
||||||
|
return when (this) {
|
||||||
|
ReaderScreenOrientationMode.FOLLOW_SYSTEM -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
ReaderScreenOrientationMode.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||||
|
ReaderScreenOrientationMode.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReaderScreenOrientationEffect(mode: ReaderScreenOrientationMode) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity: Activity? = remember(context) { context.findReaderOrientationActivity() }
|
||||||
|
|
||||||
|
DisposableEffect(activity, mode) {
|
||||||
|
if (activity != null) {
|
||||||
|
val originalOrientation = activity.requestedOrientation
|
||||||
|
activity.requestedOrientation = mode.toRequestedOrientation()
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
activity.requestedOrientation = originalOrientation
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReaderScreenOrientationPicker(
|
||||||
|
selectedMode: ReaderScreenOrientationMode,
|
||||||
|
onModeSelected: (ReaderScreenOrientationMode) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
ReaderScreenOrientationMode.entries.forEach { mode ->
|
||||||
|
val selected = mode == selectedMode
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.clickable { onModeSelected(mode) }
|
||||||
|
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = mode.title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReaderScreenOrientationSheet(
|
||||||
|
selectedMode: ReaderScreenOrientationMode,
|
||||||
|
onModeSelected: (ReaderScreenOrientationMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentWindowInsets = { WindowInsets.navigationBars }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.visual_options_screen_orientation),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ReaderScreenOrientationPicker(
|
||||||
|
selectedMode = selectedMode,
|
||||||
|
onModeSelected = onModeSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tailrec fun Context.findReaderOrientationActivity(): Activity? {
|
||||||
|
return when (this) {
|
||||||
|
is Activity -> this
|
||||||
|
is ContextWrapper -> baseContext.findReaderOrientationActivity()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
583
app/src/main/java/com/aryan/reader/SettingsScreen.kt
Normal file
583
app/src/main/java/com/aryan/reader/SettingsScreen.kt
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
|
import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
|
||||||
|
import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode
|
||||||
|
import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition
|
||||||
|
import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
|
||||||
|
import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
|
||||||
|
import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode
|
||||||
|
import com.aryan.reader.epubreader.loadFormatSettings
|
||||||
|
import com.aryan.reader.epubreader.loadPageInfoMode
|
||||||
|
import com.aryan.reader.epubreader.loadPageInfoPosition
|
||||||
|
import com.aryan.reader.epubreader.loadPullToTurn
|
||||||
|
import com.aryan.reader.epubreader.loadPullToTurnMultiplier
|
||||||
|
import com.aryan.reader.epubreader.loadSystemUiMode
|
||||||
|
import com.aryan.reader.epubreader.savePageInfoMode
|
||||||
|
import com.aryan.reader.epubreader.savePageInfoPosition
|
||||||
|
import com.aryan.reader.epubreader.savePullToTurn
|
||||||
|
import com.aryan.reader.epubreader.savePullToTurnMultiplier
|
||||||
|
import com.aryan.reader.epubreader.saveReaderSettings
|
||||||
|
import com.aryan.reader.epubreader.saveSystemUiMode
|
||||||
|
import com.aryan.reader.pdf.savePdfSystemUiMode
|
||||||
|
import com.aryan.reader.pdf.savePdfThemeId
|
||||||
|
import com.aryan.reader.pdf.savePdfVerticalPageGapVisible
|
||||||
|
import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible
|
||||||
|
import com.aryan.reader.pdf.loadPdfSystemUiMode
|
||||||
|
import com.aryan.reader.pdf.loadPdfThemeId
|
||||||
|
import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
|
||||||
|
import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
|
||||||
|
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||||
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode
|
||||||
|
import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition
|
||||||
|
import com.aryan.reader.shared.SharedSettingsAction
|
||||||
|
import com.aryan.reader.shared.SharedSettingsDestination
|
||||||
|
import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode
|
||||||
|
import com.aryan.reader.shared.parentDestination
|
||||||
|
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||||
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||||
|
import com.aryan.reader.shared.readerThemeById
|
||||||
|
import com.aryan.reader.shared.sharedSettingsHubModel
|
||||||
|
import com.aryan.reader.shared.toReaderSettings
|
||||||
|
import com.aryan.reader.shared.ui.SharedSettingsHub
|
||||||
|
import com.aryan.reader.tts.loadTtsMode
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val ANDROID_SETTINGS_GLOBAL_BOOK_ID = "__global_reader_defaults__"
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
@kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
navController: NavHostController,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||||
|
val ttsState by viewModel.ttsController.ttsState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) }
|
||||||
|
var showAppThemePanel by remember { mutableStateOf(false) }
|
||||||
|
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||||
|
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||||
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
var showClearAllDataDialog by remember { mutableStateOf(false) }
|
||||||
|
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||||
|
var showAboutDialog by remember { mutableStateOf(false) }
|
||||||
|
var showSignOutConfirmDialog by remember { mutableStateOf(false) }
|
||||||
|
var showUpgradeDialog by remember { mutableStateOf(false) }
|
||||||
|
var showRecentLimitDialog by remember { mutableStateOf(false) }
|
||||||
|
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
var hideReaderAi by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
|
||||||
|
var epubReaderDefaults by remember(context, uiState.renderMode) {
|
||||||
|
mutableStateOf(loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode))
|
||||||
|
}
|
||||||
|
var pdfReaderDefaults by remember(context) {
|
||||||
|
mutableStateOf(loadAndroidPdfReaderDefaultSettings(context))
|
||||||
|
}
|
||||||
|
var ttsReplacementPreferences by remember(context) {
|
||||||
|
mutableStateOf(loadTtsReplacementPreferences(context))
|
||||||
|
}
|
||||||
|
var ttsMode by remember(context) { mutableStateOf(loadTtsMode(context)) }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.renderMode) {
|
||||||
|
epubReaderDefaults = loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedFonts = remember(customFonts) {
|
||||||
|
customFonts.toSharedCustomFontItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsModel = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = uiState,
|
||||||
|
hideReaderAi = hideReaderAi
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsPage = settingsModel.page(settingsDestination)
|
||||||
|
|
||||||
|
fun navigateBackFromSettings() {
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
query = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parent = settingsDestination.parentDestination()
|
||||||
|
if (parent != null) {
|
||||||
|
settingsDestination = parent
|
||||||
|
} else {
|
||||||
|
onBackClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = query.isNotBlank() || settingsDestination != SharedSettingsDestination.ROOT) {
|
||||||
|
navigateBackFromSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
CustomTopAppBar(
|
||||||
|
title = { Text(settingsPage.title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = ::navigateBackFromSettings) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentWindowInsets = WindowInsets.navigationBars
|
||||||
|
) { padding ->
|
||||||
|
SharedSettingsHub(
|
||||||
|
model = settingsModel,
|
||||||
|
query = query,
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
readerDefaultSettings = epubReaderDefaults,
|
||||||
|
onReaderDefaultSettingsChange = { settings ->
|
||||||
|
epubReaderDefaults = settings
|
||||||
|
saveAndroidEpubReaderDefaultSettings(context, settings)
|
||||||
|
viewModel.setRenderMode(settings.toAndroidRenderMode())
|
||||||
|
},
|
||||||
|
pdfReaderDefaultSettings = pdfReaderDefaults,
|
||||||
|
onPdfReaderDefaultSettingsChange = { settings ->
|
||||||
|
pdfReaderDefaults = settings
|
||||||
|
saveAndroidPdfReaderDefaultSettings(context, settings)
|
||||||
|
},
|
||||||
|
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||||
|
onTtsReplacementPreferencesChange = { preferences ->
|
||||||
|
ttsReplacementPreferences = preferences
|
||||||
|
saveTtsReplacementPreferences(context, preferences)
|
||||||
|
},
|
||||||
|
customFonts = sharedFonts,
|
||||||
|
showTopBar = false,
|
||||||
|
destination = settingsDestination,
|
||||||
|
onDestinationChange = { settingsDestination = it },
|
||||||
|
contentPadding = padding,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onAction = { action ->
|
||||||
|
when (action) {
|
||||||
|
SharedSettingsAction.APP_THEME -> showAppThemePanel = true
|
||||||
|
SharedSettingsAction.LANGUAGE -> showLanguageDialog = true
|
||||||
|
SharedSettingsAction.TABS_TOGGLE -> viewModel.setTabsEnabled(!uiState.isTabsEnabled)
|
||||||
|
SharedSettingsAction.RECENT_LIMIT -> showRecentLimitDialog = true
|
||||||
|
SharedSettingsAction.STRICT_FILE_FILTER -> {
|
||||||
|
if (uiState.useStrictFileFilter) {
|
||||||
|
viewModel.setStrictFileFilter(false)
|
||||||
|
} else {
|
||||||
|
showStrictFilterDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true
|
||||||
|
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> {
|
||||||
|
val next = !uiState.isScreenCaptureProtectionEnabled
|
||||||
|
viewModel.setScreenCaptureProtectionEnabled(next)
|
||||||
|
val messageRes = if (next) {
|
||||||
|
R.string.banner_screen_capture_protection_on
|
||||||
|
} else {
|
||||||
|
R.string.banner_screen_capture_protection_off
|
||||||
|
}
|
||||||
|
viewModel.showBanner(context.getString(messageRes))
|
||||||
|
}
|
||||||
|
SharedSettingsAction.CUSTOM_FONTS -> navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
|
||||||
|
SharedSettingsAction.SIGN_IN -> {
|
||||||
|
scope.launch {
|
||||||
|
context.findActivity()?.let { activity -> viewModel.signIn(activity) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedSettingsAction.SIGN_OUT -> showSignOutConfirmDialog = true
|
||||||
|
SharedSettingsAction.CLOUD_SYNC -> {
|
||||||
|
if (uiState.isProUser) {
|
||||||
|
viewModel.setSyncEnabled(!uiState.isSyncEnabled)
|
||||||
|
} else {
|
||||||
|
showUpgradeDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedSettingsAction.FOLDER_SYNC -> viewModel.setFolderSyncEnabled(!uiState.isFolderSyncEnabled)
|
||||||
|
SharedSettingsAction.DEVICE_MANAGEMENT -> viewModel.showDeviceManagementForDebug()
|
||||||
|
SharedSettingsAction.AI_SETTINGS -> navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
||||||
|
SharedSettingsAction.HIDE_READER_AI -> {
|
||||||
|
val nextHidden = !hideReaderAi
|
||||||
|
saveHideReaderAiFeatures(context, nextHidden)
|
||||||
|
hideReaderAi = nextHidden
|
||||||
|
}
|
||||||
|
SharedSettingsAction.TTS_SETTINGS -> showTtsSettingsSheet = true
|
||||||
|
SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true
|
||||||
|
SharedSettingsAction.CLEAR_REFLOW_CACHE -> showClearReflowCacheDialog = true
|
||||||
|
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> showClearAllDataDialog = true
|
||||||
|
SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context)
|
||||||
|
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context)
|
||||||
|
SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context)
|
||||||
|
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.")
|
||||||
|
SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE)
|
||||||
|
SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE)
|
||||||
|
SharedSettingsAction.ABOUT -> showAboutDialog = true
|
||||||
|
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.")
|
||||||
|
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||||
|
SharedSettingsAction.READER_TOOLBAR,
|
||||||
|
SharedSettingsAction.TTS_REPLACEMENTS,
|
||||||
|
SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRecentLimitDialog) {
|
||||||
|
RecentLimitDialog(
|
||||||
|
currentLimit = uiState.recentFilesLimit,
|
||||||
|
onSelect = { limit ->
|
||||||
|
viewModel.setRecentFilesLimit(limit)
|
||||||
|
showRecentLimitDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showRecentLimitDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBehaviorDialog) {
|
||||||
|
ExternalFileBehaviorDialog(
|
||||||
|
currentBehavior = uiState.externalFileBehavior,
|
||||||
|
onDismiss = { showBehaviorDialog = false },
|
||||||
|
onSelect = { viewModel.setExternalFileBehavior(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showStrictFilterDialog) {
|
||||||
|
StrictFilterConfirmationDialog(
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setStrictFileFilter(true)
|
||||||
|
showStrictFilterDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showStrictFilterDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showClearBookCacheDialog) {
|
||||||
|
DangerousFolderActionDialog(
|
||||||
|
title = context.getString(R.string.dialog_clear_book_cache),
|
||||||
|
message = context.getString(R.string.dialog_clear_book_cache_desc),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.clearBookCache()
|
||||||
|
showClearBookCacheDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showClearBookCacheDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showClearReflowCacheDialog) {
|
||||||
|
DangerousFolderActionDialog(
|
||||||
|
title = context.getString(R.string.dialog_clear_reflow_cache),
|
||||||
|
message = context.getString(R.string.dialog_clear_reflow_cache_desc),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.clearReflowCache()
|
||||||
|
showClearReflowCacheDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showClearReflowCacheDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showClearAllDataDialog) {
|
||||||
|
ClearAllDataConfirmationDialog(
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.deleteAllCloudAndLocalData()
|
||||||
|
showClearAllDataDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showClearAllDataDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLanguageDialog) {
|
||||||
|
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppThemePanel) {
|
||||||
|
AppThemeBottomSheet(
|
||||||
|
uiState = uiState,
|
||||||
|
onThemeModeChanged = viewModel::setAppThemeMode,
|
||||||
|
onContrastOptionChanged = viewModel::setAppContrastOption,
|
||||||
|
onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
|
||||||
|
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
|
||||||
|
onSeedColorChanged = viewModel::setAppSeedColor,
|
||||||
|
onCustomThemeAdded = viewModel::addCustomAppTheme,
|
||||||
|
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
|
||||||
|
onDismiss = { showAppThemePanel = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAboutDialog) {
|
||||||
|
AboutDialog(onDismiss = { showAboutDialog = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSignOutConfirmDialog) {
|
||||||
|
SignOutConfirmationDialog(
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.signOut()
|
||||||
|
showSignOutConfirmDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showSignOutConfirmDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUpgradeDialog) {
|
||||||
|
UpgradeDialog(
|
||||||
|
onConfirm = {
|
||||||
|
showUpgradeDialog = false
|
||||||
|
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||||
|
},
|
||||||
|
onDismiss = { showUpgradeDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTtsSettingsSheet) {
|
||||||
|
TtsSettingsSheet(
|
||||||
|
isVisible = true,
|
||||||
|
onDismiss = { showTtsSettingsSheet = false },
|
||||||
|
currentMode = ttsMode,
|
||||||
|
onModeChange = { mode ->
|
||||||
|
ttsMode = mode
|
||||||
|
viewModel.ttsController.changeTtsMode(mode.name)
|
||||||
|
},
|
||||||
|
currentSpeakerId = ttsState.speakerId,
|
||||||
|
onSpeakerChange = viewModel.ttsController::changeSpeaker,
|
||||||
|
isTtsActive = ttsState.isPlaying,
|
||||||
|
getAuthToken = { viewModel.getAuthToken() },
|
||||||
|
bookTitle = "Reader defaults"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.deviceLimitState.isLimitReached) {
|
||||||
|
DeviceManagementScreen(
|
||||||
|
devices = uiState.deviceLimitState.registeredDevices,
|
||||||
|
onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) },
|
||||||
|
isReplacing = uiState.isReplacingDevice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentLimitDialog(
|
||||||
|
currentLimit: Int,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Recent files limit") },
|
||||||
|
text = {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
listOf(0, 10, 20, 50, 100).forEach { limit ->
|
||||||
|
TextButton(onClick = { onSelect(limit) }) {
|
||||||
|
val label = if (limit == 0) "No limit" else "$limit files"
|
||||||
|
Text(if (currentLimit == limit) "$label selected" else label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAndroidEpubReaderDefaultSettings(
|
||||||
|
context: Context,
|
||||||
|
renderMode: RenderMode
|
||||||
|
): ReaderSettings {
|
||||||
|
val format = loadFormatSettings(context, ANDROID_SETTINGS_GLOBAL_BOOK_ID, isLocal = false)
|
||||||
|
val horizontalMargin = (48f * format.horizontalMargin).roundToInt().coerceIn(0, 160)
|
||||||
|
val verticalMargin = (48f * format.verticalMargin).roundToInt().coerceIn(0, 160)
|
||||||
|
val base = ReaderSettings(
|
||||||
|
fontSize = (18f * format.fontSize).roundToInt().coerceIn(12, 42),
|
||||||
|
lineSpacing = (1.45f * format.lineHeight).coerceIn(1.0f, 2.8f),
|
||||||
|
margin = max(horizontalMargin, verticalMargin),
|
||||||
|
readingMode = renderMode.toSharedReaderReadingMode(),
|
||||||
|
textAlign = format.textAlign.toSharedReaderTextAlign(),
|
||||||
|
fontFamily = format.toSharedFontFamilyName(),
|
||||||
|
paragraphSpacing = format.paragraphGap.coerceIn(0.5f, 2.5f),
|
||||||
|
imageScale = format.imageSize.coerceIn(0.5f, 2.0f),
|
||||||
|
horizontalMargin = horizontalMargin,
|
||||||
|
verticalMargin = verticalMargin,
|
||||||
|
themeId = loadReaderThemeId(context),
|
||||||
|
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
|
||||||
|
customFontPath = format.customPath?.takeIf { it.isNotBlank() },
|
||||||
|
systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(),
|
||||||
|
pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(),
|
||||||
|
pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(),
|
||||||
|
seamlessChapterNavigation = loadPullToTurn(context),
|
||||||
|
chapterTurnDragMultiplier = loadPullToTurnMultiplier(context)
|
||||||
|
)
|
||||||
|
return readerThemeById(base.themeId)?.toReaderSettings(base) ?: base
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAndroidPdfReaderDefaultSettings(
|
||||||
|
context: Context
|
||||||
|
): ReaderSettings {
|
||||||
|
val base = ReaderSettings(
|
||||||
|
themeId = loadPdfThemeId(context),
|
||||||
|
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
|
||||||
|
systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(),
|
||||||
|
pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context),
|
||||||
|
pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context)
|
||||||
|
)
|
||||||
|
return BuiltInPdfReaderThemes.firstOrNull { it.id == base.themeId }?.toReaderSettings(base) ?: base
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAndroidEpubReaderDefaultSettings(
|
||||||
|
context: Context,
|
||||||
|
settings: ReaderSettings
|
||||||
|
) {
|
||||||
|
saveReaderSettings(
|
||||||
|
context = context,
|
||||||
|
fontSize = (settings.fontSize / 18f).coerceIn(0.65f, 2.4f),
|
||||||
|
lineHeight = (settings.lineSpacing / 1.45f).coerceIn(0.7f, 2.0f),
|
||||||
|
paragraphGap = settings.paragraphSpacing.coerceIn(0.5f, 2.5f),
|
||||||
|
imageSize = settings.imageScale.coerceIn(0.5f, 2.0f),
|
||||||
|
horizontalMargin = (settings.resolvedHorizontalMargin / 48f).coerceIn(0f, 3.4f),
|
||||||
|
verticalMargin = (settings.resolvedVerticalMargin / 48f).coerceIn(0f, 3.4f),
|
||||||
|
fontFamily = settings.toAndroidReaderFont(),
|
||||||
|
customFontPath = settings.customFontPath,
|
||||||
|
textAlign = settings.textAlign.toAndroidTextAlign()
|
||||||
|
)
|
||||||
|
saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
|
||||||
|
savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode())
|
||||||
|
savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition())
|
||||||
|
savePullToTurn(context, settings.seamlessChapterNavigation)
|
||||||
|
savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier)
|
||||||
|
saveReaderThemeId(context, settings.themeId ?: "system")
|
||||||
|
saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAndroidPdfReaderDefaultSettings(
|
||||||
|
context: Context,
|
||||||
|
settings: ReaderSettings
|
||||||
|
) {
|
||||||
|
savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
|
||||||
|
savePdfThemeId(context, settings.themeId ?: "no_theme")
|
||||||
|
savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible)
|
||||||
|
savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible)
|
||||||
|
saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||||
|
return filterNot { it.isDeleted }
|
||||||
|
.sortedBy { it.displayName.lowercase() }
|
||||||
|
.map { font ->
|
||||||
|
CustomFontItem(
|
||||||
|
id = font.id,
|
||||||
|
displayName = font.displayName,
|
||||||
|
fileName = font.fileName,
|
||||||
|
fileExtension = font.fileExtension,
|
||||||
|
path = font.path,
|
||||||
|
timestamp = font.timestamp,
|
||||||
|
isDeleted = font.isDeleted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
|
||||||
|
return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() }
|
||||||
|
?: when (font) {
|
||||||
|
AndroidReaderFont.ORIGINAL -> "Default"
|
||||||
|
AndroidReaderFont.MERRIWEATHER,
|
||||||
|
AndroidReaderFont.LORA -> "Serif"
|
||||||
|
AndroidReaderFont.LATO,
|
||||||
|
AndroidReaderFont.LEXEND -> "Sans"
|
||||||
|
AndroidReaderFont.ROBOTO_MONO -> "Mono"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||||
|
return when (this) {
|
||||||
|
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||||
|
AndroidReaderTextAlign.DEFAULT,
|
||||||
|
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
|
||||||
|
return when (fontFamily) {
|
||||||
|
"Serif" -> AndroidReaderFont.LORA
|
||||||
|
"Sans" -> AndroidReaderFont.LATO
|
||||||
|
"Mono" -> AndroidReaderFont.ROBOTO_MONO
|
||||||
|
else -> AndroidReaderFont.ORIGINAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign {
|
||||||
|
return when (this) {
|
||||||
|
SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY
|
||||||
|
SharedReaderTextAlign.CENTER,
|
||||||
|
SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RenderMode.toSharedReaderReadingMode(): ReaderReadingMode {
|
||||||
|
return when (this) {
|
||||||
|
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
|
||||||
|
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderSettings.toAndroidRenderMode(): RenderMode {
|
||||||
|
return when (readingMode) {
|
||||||
|
ReaderReadingMode.PAGINATED -> RenderMode.PAGINATED
|
||||||
|
ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode {
|
||||||
|
return SharedSystemUiMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode {
|
||||||
|
return AndroidSystemUiMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode {
|
||||||
|
return SharedPageInfoMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode {
|
||||||
|
return AndroidPageInfoMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition {
|
||||||
|
return SharedPageInfoPosition.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition {
|
||||||
|
return AndroidPageInfoPosition.valueOf(name)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -17,9 +17,10 @@ import com.aryan.reader.shared.FileType as SharedFileType
|
||||||
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||||
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
||||||
import com.aryan.reader.shared.RenderMode as SharedRenderMode
|
import com.aryan.reader.shared.RenderMode as SharedRenderMode
|
||||||
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
|
||||||
import com.aryan.reader.shared.SharedReaderScreenState
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||||
import com.aryan.reader.shared.ShelfRecord
|
import com.aryan.reader.shared.ShelfRecord
|
||||||
|
import com.aryan.reader.shared.ShelfType as SharedShelfType
|
||||||
import com.aryan.reader.shared.SortOrder as SharedSortOrder
|
import com.aryan.reader.shared.SortOrder as SharedSortOrder
|
||||||
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||||
import com.aryan.reader.shared.Tag as SharedTag
|
import com.aryan.reader.shared.Tag as SharedTag
|
||||||
|
|
@ -34,18 +35,88 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
author = author,
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
progressPercentage = progressPercentage,
|
progressPercentage = progressPercentage,
|
||||||
isRecent = isRecent,
|
isRecent = isRecent,
|
||||||
fileSize = fileSize,
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
sourceFolder = sourceFolderUri,
|
sourceFolder = sourceFolderUri,
|
||||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
seriesIndex = seriesIndex,
|
||||||
|
lastPageIndex = lastPage,
|
||||||
tags = tags.map { it.toSharedTag() },
|
tags = tags.map { it.toSharedTag() },
|
||||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
|
||||||
|
return toSharedBookItem().copy(displayName = displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedBookItem.toRecentFileItem(
|
||||||
|
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
||||||
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
|
): RecentFileItem {
|
||||||
|
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
||||||
|
return androidBooksById[id]?.copy(tags = resolvedTags)
|
||||||
|
?.copy(
|
||||||
|
uriString = path,
|
||||||
|
type = type.toAndroidFileType(),
|
||||||
|
displayName = androidBooksById[id]?.displayName ?: displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
lastPage = lastPageIndex,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
sourceFolderUri = sourceFolder,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed
|
||||||
|
)
|
||||||
|
?: RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = path,
|
||||||
|
type = type.toAndroidFileType(),
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
lastPage = lastPageIndex,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
sourceFolderUri = sourceFolder,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
tags = resolvedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun TagEntity.toSharedTag(): SharedTag {
|
fun TagEntity.toSharedTag(): SharedTag {
|
||||||
return SharedTag(
|
return SharedTag(
|
||||||
id = id,
|
id = id,
|
||||||
|
|
@ -153,92 +224,147 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ReaderScreenState.toSharedLibraryProjectionInput(
|
fun List<RecentFileItem>.withResolvedTags(
|
||||||
recentFilesFromDb: List<RecentFileItem>,
|
|
||||||
dbShelves: List<ShelfEntity>,
|
|
||||||
shelfRefs: List<BookShelfCrossRef>,
|
|
||||||
dbTags: List<TagEntity>,
|
dbTags: List<TagEntity>,
|
||||||
tagRefs: List<BookTagCrossRef>
|
tagRefs: List<BookTagCrossRef>
|
||||||
): SharedLibraryProjectionInput {
|
): List<RecentFileItem> {
|
||||||
val tagsById = dbTags.associateBy { it.id }
|
val tagsById = dbTags.associateBy { it.id }
|
||||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||||
}
|
}
|
||||||
val taggedBooks = recentFilesFromDb.map { item ->
|
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
|
||||||
item.copy(tags = bookTagsMap[item.bookId].orEmpty())
|
}
|
||||||
|
|
||||||
|
fun SharedReaderScreenState.toAndroidReaderScreenState(
|
||||||
|
base: ReaderScreenState,
|
||||||
|
androidBooksById: Map<String, RecentFileItem>,
|
||||||
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
|
): ReaderScreenState {
|
||||||
|
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
|
||||||
|
fun SharedBookItem.toAndroidBook(): RecentFileItem {
|
||||||
|
return toRecentFileItem(androidBooksById, tagEntitiesById)
|
||||||
}
|
}
|
||||||
return SharedLibraryProjectionInput(
|
fun bookById(bookId: String): RecentFileItem? {
|
||||||
state = toSharedReaderScreenState(
|
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
|
||||||
rawBooks = taggedBooks,
|
}
|
||||||
dbTags = dbTags
|
return base.copy(
|
||||||
),
|
recentFiles = recentBooks.map { it.toAndroidBook() },
|
||||||
booksFromStore = taggedBooks
|
allRecentFiles = libraryBooks.map { it.toAndroidBook() },
|
||||||
.filterNot { it.bookId.endsWith("_reflow") }
|
rawLibraryFiles = rawLibraryBooks.map { it.toAndroidBook() },
|
||||||
.map { it.toSharedBookItem() },
|
viewingShelfId = viewingShelfId,
|
||||||
shelfRecords = dbShelves.map { it.toSharedShelfRecord() },
|
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||||
shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() },
|
contextualActionShelfIds = selectedShelfIds,
|
||||||
tags = dbTags.map { it.toSharedTag() }
|
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
|
||||||
|
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
|
||||||
|
openTabs = openTabs.map { it.toAndroidBook() },
|
||||||
|
openTabIds = openTabIds,
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
|
booksAvailableForAdding = booksAvailableForAdding.map { it.toAndroidBook() },
|
||||||
|
allTags = allTags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedShelf.toAndroidShelf(
|
||||||
|
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
|
||||||
|
tagEntitiesById: Map<String, TagEntity> = emptyMap()
|
||||||
|
): Shelf {
|
||||||
|
return Shelf(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
type = type.toAndroidShelfType(),
|
||||||
|
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||||
|
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||||
|
parentShelfId = parentShelfId,
|
||||||
|
childShelfIds = childShelfIds,
|
||||||
|
depth = depth,
|
||||||
|
sortKey = sortKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun FileType.toSharedFileType(): SharedFileType {
|
fun FileType.toSharedFileType(): SharedFileType {
|
||||||
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
fun SharedFileType.toAndroidFileType(): FileType {
|
||||||
return SharedRenderMode.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
||||||
return SharedAddBooksSource.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
|
||||||
return SharedSortOrder.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
||||||
return SharedReadStatusFilter.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
|
||||||
return SharedLibraryFilters(
|
return this
|
||||||
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
|
|
||||||
sourceFolders = sourceFolders,
|
|
||||||
readStatus = readStatus.toSharedReadStatusFilter(),
|
|
||||||
tagIds = tagIds
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
||||||
return SharedSyncedFolder(
|
return this
|
||||||
uriString = uriString,
|
|
||||||
name = name,
|
|
||||||
lastScanTime = lastScanTime,
|
|
||||||
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
|
||||||
return SharedBannerMessage(
|
return this
|
||||||
message = message,
|
|
||||||
isError = isError,
|
|
||||||
isPersistent = isPersistent
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
||||||
return SharedAppThemeMode.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
|
||||||
return SharedAppContrastOption.valueOf(name)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
||||||
return SharedCustomAppTheme(
|
return this
|
||||||
id = id,
|
}
|
||||||
name = name,
|
|
||||||
seedColor = seedColor
|
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
|
||||||
)
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedShelfType.toAndroidShelfType(): ShelfType {
|
||||||
|
return ShelfType.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
||||||
191
app/src/main/java/com/aryan/reader/ThemedBookCover.kt
Normal file
191
app/src/main/java/com/aryan/reader/ThemedBookCover.kt
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ThemedBookCover(
|
||||||
|
item: RecentFileItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = item.displayName,
|
||||||
|
contentScale: ContentScale = ContentScale.Crop
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val coverFile = remember(item.coverImagePath) {
|
||||||
|
item.coverImagePath
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let(::File)
|
||||||
|
?.takeIf { it.isFile }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
GeneratedBookCover(item = item, modifier = Modifier.fillMaxSize())
|
||||||
|
if (coverFile != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(coverFile)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GeneratedBookCover(
|
||||||
|
item: RecentFileItem,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val seed = remember(item.bookId, item.displayName) {
|
||||||
|
val hash = (item.bookId.ifBlank { item.displayName }).hashCode()
|
||||||
|
if (hash == Int.MIN_VALUE) 0 else hash.absoluteValue
|
||||||
|
}
|
||||||
|
val baseOptions = listOf(
|
||||||
|
colorScheme.primaryContainer,
|
||||||
|
colorScheme.secondaryContainer,
|
||||||
|
colorScheme.tertiaryContainer,
|
||||||
|
lerp(colorScheme.primary, colorScheme.surface, 0.30f),
|
||||||
|
lerp(colorScheme.secondary, colorScheme.surface, 0.26f)
|
||||||
|
)
|
||||||
|
val accentOptions = listOf(
|
||||||
|
colorScheme.primary,
|
||||||
|
colorScheme.secondary,
|
||||||
|
colorScheme.tertiary,
|
||||||
|
colorScheme.inversePrimary
|
||||||
|
)
|
||||||
|
val base = baseOptions[seed % baseOptions.size]
|
||||||
|
val accent = accentOptions[(seed / 7) % accentOptions.size]
|
||||||
|
val title = item.coverTitle()
|
||||||
|
val author = item.coverAuthor()
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.background(
|
||||||
|
Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
lerp(base, colorScheme.surface, 0.06f),
|
||||||
|
lerp(base, accent, 0.16f),
|
||||||
|
lerp(colorScheme.surfaceContainerHighest, base, 0.34f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(0.5.dp, colorScheme.outlineVariant.copy(alpha = 0.35f))
|
||||||
|
) {
|
||||||
|
val compact = maxWidth < 80.dp
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(if (compact) 7.dp else 10.dp)
|
||||||
|
.background(accent.copy(alpha = 0.42f))
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.72f)
|
||||||
|
.height(if (compact) 5.dp else 7.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(y = if (compact) 8.dp else 12.dp)
|
||||||
|
.background(colorScheme.surface.copy(alpha = 0.30f))
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.48f)
|
||||||
|
.height(if (compact) 4.dp else 6.dp)
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.offset(x = if (compact) 12.dp else 18.dp, y = if (compact) (-10).dp else (-16).dp)
|
||||||
|
.background(accent.copy(alpha = 0.26f))
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
start = if (compact) 12.dp else 18.dp,
|
||||||
|
top = if (compact) 10.dp else 18.dp,
|
||||||
|
end = if (compact) 8.dp else 14.dp,
|
||||||
|
bottom = if (compact) 10.dp else 16.dp
|
||||||
|
),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = colorScheme.onSurface,
|
||||||
|
fontSize = if (compact) 12.sp else 17.sp,
|
||||||
|
lineHeight = if (compact) 14.sp else 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (author != null && !compact) {
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(1.dp)
|
||||||
|
.background(accent.copy(alpha = 0.55f))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = author,
|
||||||
|
color = colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.coverTitle(): String {
|
||||||
|
return customName
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: title?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
|
||||||
|
?: displayName.substringBeforeLast('.', missingDelimiterValue = displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.coverAuthor(): String? {
|
||||||
|
return author
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
TagEntity::class,
|
TagEntity::class,
|
||||||
BookTagCrossRef::class
|
BookTagCrossRef::class
|
||||||
],
|
],
|
||||||
version = 19,
|
version = 22,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
|
|
@ -257,6 +257,37 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_19_20 = object : Migration(19, 20) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderCoverMetadataParsed INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_20_21 = object : Migration(20, 21) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalTitle TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalAuthor TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesName TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesIndex REAL DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalDescription TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("""
|
||||||
|
UPDATE recent_files
|
||||||
|
SET
|
||||||
|
originalTitle = title,
|
||||||
|
originalAuthor = author,
|
||||||
|
originalSeriesName = seriesName,
|
||||||
|
originalSeriesIndex = seriesIndex,
|
||||||
|
originalDescription = description
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_21_22 = object : Migration(21, 22) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN fileContentModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -269,7 +300,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
|
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
|
||||||
|
MIGRATION_20_21, MIGRATION_21_22
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.aryan.reader.data
|
||||||
|
|
||||||
|
data class BookMetadataEdit(
|
||||||
|
val title: String?,
|
||||||
|
val author: String?,
|
||||||
|
val seriesName: String?,
|
||||||
|
val seriesIndex: Double?,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
|
|
@ -20,13 +20,19 @@ data class FolderBookMetadata(
|
||||||
val locatorBlockIndex: Int?,
|
val locatorBlockIndex: Int?,
|
||||||
val locatorCharOffset: Int?,
|
val locatorCharOffset: Int?,
|
||||||
val customName: String?,
|
val customName: String?,
|
||||||
val highlightsJson: String?
|
val highlightsJson: String?,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val originalTitle: String? = null,
|
||||||
|
val originalAuthor: String? = null,
|
||||||
|
val originalSeriesName: String? = null,
|
||||||
|
val originalSeriesIndex: Double? = null,
|
||||||
|
val originalDescription: String? = null
|
||||||
) {
|
) {
|
||||||
fun toJsonString(): String {
|
fun toJsonString(): String {
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
json.put("bookId", bookId)
|
json.put("bookId", bookId)
|
||||||
json.put("title", title)
|
|
||||||
json.put("author", author)
|
|
||||||
json.put("displayName", displayName)
|
json.put("displayName", displayName)
|
||||||
json.put("type", type)
|
json.put("type", type)
|
||||||
json.put("lastChapterIndex", lastChapterIndex ?: -1)
|
json.put("lastChapterIndex", lastChapterIndex ?: -1)
|
||||||
|
|
@ -58,8 +64,8 @@ data class FolderBookMetadata(
|
||||||
|
|
||||||
return FolderBookMetadata(
|
return FolderBookMetadata(
|
||||||
bookId = json.getString("bookId"),
|
bookId = json.getString("bookId"),
|
||||||
title = json.optStringNull("title"),
|
title = null,
|
||||||
author = json.optStringNull("author"),
|
author = null,
|
||||||
displayName = json.optString("displayName", "Unknown"),
|
displayName = json.optString("displayName", "Unknown"),
|
||||||
type = json.optString("type", "PDF"),
|
type = json.optString("type", "PDF"),
|
||||||
lastChapterIndex = json.optIntNull("lastChapterIndex"),
|
lastChapterIndex = json.optIntNull("lastChapterIndex"),
|
||||||
|
|
@ -72,7 +78,15 @@ data class FolderBookMetadata(
|
||||||
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
||||||
locatorCharOffset = json.optIntNull("locatorCharOffset"),
|
locatorCharOffset = json.optIntNull("locatorCharOffset"),
|
||||||
customName = json.optStringNull("customName"),
|
customName = json.optStringNull("customName"),
|
||||||
highlightsJson = json.optStringNull("highlightsJson")
|
highlightsJson = json.optStringNull("highlightsJson"),
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
originalTitle = null,
|
||||||
|
originalAuthor = null,
|
||||||
|
originalSeriesName = null,
|
||||||
|
originalSeriesIndex = null,
|
||||||
|
originalDescription = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,8 +100,8 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
||||||
displayName = this.displayName,
|
displayName = this.displayName,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
coverImagePath = coverPath,
|
coverImagePath = coverPath,
|
||||||
title = this.title,
|
title = this.displayName.substringBeforeLast('.', this.displayName),
|
||||||
author = this.author,
|
author = null,
|
||||||
lastChapterIndex = this.lastChapterIndex,
|
lastChapterIndex = this.lastChapterIndex,
|
||||||
lastPage = this.lastPage,
|
lastPage = this.lastPage,
|
||||||
lastPositionCfi = this.lastPositionCfi,
|
lastPositionCfi = this.lastPositionCfi,
|
||||||
|
|
@ -101,6 +115,14 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
sourceFolderUri = sourceFolderUri,
|
sourceFolderUri = sourceFolderUri,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = this.highlightsJson
|
highlightsJson = this.highlightsJson,
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
originalTitle = null,
|
||||||
|
originalAuthor = null,
|
||||||
|
originalSeriesName = null,
|
||||||
|
originalSeriesIndex = null,
|
||||||
|
originalDescription = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ import android.os.Environment
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.aryan.reader.ReaderPerfLog
|
import com.aryan.reader.ReaderPerfLog
|
||||||
|
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
||||||
|
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
||||||
|
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
||||||
|
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
||||||
|
import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
|
||||||
|
import com.aryan.reader.shared.localFolderSyncSidecarStem
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -22,6 +28,12 @@ object LocalSyncUtils {
|
||||||
val uri: Uri
|
val uri: Uri
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ParsedAnnotationSidecar(
|
||||||
|
val bookId: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val data: String
|
||||||
|
)
|
||||||
|
|
||||||
private fun syncSubfolderDocId(rootDocId: String): String {
|
private fun syncSubfolderDocId(rootDocId: String): String {
|
||||||
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
|
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
|
||||||
rootDocId
|
rootDocId
|
||||||
|
|
@ -182,15 +194,14 @@ object LocalSyncUtils {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||||
|
|
||||||
val syncFileName = ".${metadata.bookId}.json"
|
val syncFileName = localFolderSyncMetadataFileName(metadata.bookId)
|
||||||
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
|
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
|
||||||
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||||
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
|
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempFileName = ".${metadata.bookId}.tmp"
|
val tempFileName = uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))
|
||||||
syncDir.findFile(tempFileName)?.delete()
|
|
||||||
val tempFile = syncDir.createFile("application/json", tempFileName)
|
val tempFile = syncDir.createFile("application/json", tempFileName)
|
||||||
if (tempFile == null) {
|
if (tempFile == null) {
|
||||||
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
||||||
|
|
@ -258,10 +269,8 @@ object LocalSyncUtils {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||||
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||||
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
|
val targetName = localFolderSyncAnnotationFileName(bookId)
|
||||||
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
|
val tempName = uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))
|
||||||
syncDir.findFile(tempName)?.delete()
|
|
||||||
val tempFile = syncDir.createFile("application/json", tempName)
|
|
||||||
|
|
||||||
if (currentBest != null) {
|
if (currentBest != null) {
|
||||||
val (remoteTs, _) = currentBest
|
val (remoteTs, _) = currentBest
|
||||||
|
|
@ -273,10 +282,12 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
val wrapper = JSONObject()
|
val wrapper = JSONObject()
|
||||||
wrapper.put("version", 1)
|
wrapper.put("version", 1)
|
||||||
|
wrapper.put("bookId", bookId)
|
||||||
wrapper.put("timestamp", timestamp)
|
wrapper.put("timestamp", timestamp)
|
||||||
wrapper.put("data", JSONObject(jsonPayload))
|
wrapper.put("data", JSONObject(jsonPayload))
|
||||||
val contentBytes = wrapper.toString().toByteArray()
|
val contentBytes = wrapper.toString().toByteArray()
|
||||||
|
|
||||||
|
val tempFile = syncDir.createFile("application/json", tempName)
|
||||||
if (tempFile == null) {
|
if (tempFile == null) {
|
||||||
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|
@ -325,17 +336,21 @@ object LocalSyncUtils {
|
||||||
val results = mutableMapOf<String, Pair<Long, String>>()
|
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
val parsedSidecars = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||||
.filter { file ->
|
.asSequence()
|
||||||
val name = file.name
|
.filter { isAnnotationSidecarCandidateName(it.name) }
|
||||||
extractAnnotationBookId(name) != null &&
|
.mapNotNull { file ->
|
||||||
!name.contains(".syncthing.")
|
parseAnnotationSidecar(
|
||||||
|
context = context,
|
||||||
|
file = file,
|
||||||
|
fallbackBookId = extractLegacyAnnotationBookId(file.name)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
|
.groupBy { it.bookId }
|
||||||
|
|
||||||
for ((bookId, files) in groupedFiles) {
|
for ((bookId, sidecars) in parsedSidecars) {
|
||||||
val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
|
val best = sidecars.maxByOrNull { it.timestamp }
|
||||||
if (best != null) results[bookId] = best
|
if (best != null) results[bookId] = best.timestamp to best.data
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
||||||
|
|
@ -344,44 +359,6 @@ object LocalSyncUtils {
|
||||||
return@withContext results
|
return@withContext results
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveAnnotationConflictsReadOnly(
|
|
||||||
context: Context,
|
|
||||||
bookId: String,
|
|
||||||
files: List<SyncFileEntry>
|
|
||||||
): Pair<Long, String>? {
|
|
||||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
|
||||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
|
||||||
var bestTs = -1L
|
|
||||||
var bestData: String? = null
|
|
||||||
|
|
||||||
for (file in files) {
|
|
||||||
val name = file.name
|
|
||||||
if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
|
|
||||||
name.endsWith(".json") &&
|
|
||||||
!name.endsWith(".tmp") &&
|
|
||||||
!name.contains(".syncthing."))
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
|
||||||
it.bufferedReader().readText()
|
|
||||||
} ?: continue
|
|
||||||
val json = JSONObject(content)
|
|
||||||
val ts = json.optLong("timestamp", 0L)
|
|
||||||
val data = json.optJSONObject("data")?.toString()
|
|
||||||
if (data != null && ts > bestTs) {
|
|
||||||
bestTs = ts
|
|
||||||
bestData = data
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestData?.let { bestTs to it }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAnnotationSidecar(
|
suspend fun getAnnotationSidecar(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceFolderUri: Uri,
|
sourceFolderUri: Uri,
|
||||||
|
|
@ -405,17 +382,16 @@ object LocalSyncUtils {
|
||||||
bookId: String,
|
bookId: String,
|
||||||
knownFiles: List<DocumentFile>? = null
|
knownFiles: List<DocumentFile>? = null
|
||||||
): Pair<Long, String>? {
|
): Pair<Long, String>? {
|
||||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
|
||||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
|
||||||
|
|
||||||
val allFiles = knownFiles ?: syncDir.listFiles().asList()
|
val allFiles = knownFiles ?: syncDir.listFiles().asList()
|
||||||
|
|
||||||
val candidates = allFiles.filter { file ->
|
val candidates = allFiles.mapNotNull { file ->
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: return@mapNotNull null
|
||||||
(name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
|
if (!isAnnotationSidecarCandidateName(name)) return@mapNotNull null
|
||||||
name.endsWith(".json") &&
|
parseAnnotationSidecar(
|
||||||
!name.endsWith(".tmp") &&
|
context = context,
|
||||||
!name.contains(".syncthing.")
|
file = SyncFileEntry(name = name, uri = file.uri),
|
||||||
|
fallbackBookId = extractLegacyAnnotationBookId(name)
|
||||||
|
)?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.isEmpty()) return null
|
if (candidates.isEmpty()) return null
|
||||||
|
|
@ -425,31 +401,16 @@ object LocalSyncUtils {
|
||||||
var bestFile: DocumentFile? = null
|
var bestFile: DocumentFile? = null
|
||||||
val filesToDelete = mutableListOf<DocumentFile>()
|
val filesToDelete = mutableListOf<DocumentFile>()
|
||||||
|
|
||||||
for (file in candidates) {
|
for ((file, sidecar) in candidates) {
|
||||||
try {
|
if (sidecar.timestamp > bestTs) {
|
||||||
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
if (bestFile != null) {
|
||||||
it.bufferedReader().readText()
|
filesToDelete.add(bestFile)
|
||||||
} ?: continue
|
|
||||||
|
|
||||||
val json = JSONObject(content)
|
|
||||||
val ts = json.optLong("timestamp", 0L)
|
|
||||||
val data = json.optJSONObject("data")?.toString()
|
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
if (ts > bestTs) {
|
|
||||||
if (bestFile != null) filesToDelete.add(bestFile)
|
|
||||||
|
|
||||||
bestTs = ts
|
|
||||||
bestData = data
|
|
||||||
bestFile = file
|
|
||||||
} else {
|
|
||||||
filesToDelete.add(file)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filesToDelete.add(file)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
bestTs = sidecar.timestamp
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}")
|
bestData = sidecar.data
|
||||||
|
bestFile = file
|
||||||
|
} else {
|
||||||
|
filesToDelete.add(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,7 +425,7 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestFile != null) {
|
if (bestFile != null) {
|
||||||
val correctName = "${basePattern}.json"
|
val correctName = localFolderSyncAnnotationFileName(bookId)
|
||||||
if (bestFile.name != correctName) {
|
if (bestFile.name != correctName) {
|
||||||
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName")
|
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName")
|
||||||
val existingTarget = syncDir.findFile(correctName)
|
val existingTarget = syncDir.findFile(correctName)
|
||||||
|
|
@ -548,7 +509,7 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val correctName = ".${bookId}.json"
|
val correctName = localFolderSyncMetadataFileName(bookId)
|
||||||
if (bestFile.name != correctName) {
|
if (bestFile.name != correctName) {
|
||||||
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
|
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
|
||||||
bestFile.renameTo(correctName)
|
bestFile.renameTo(correctName)
|
||||||
|
|
@ -563,21 +524,24 @@ object LocalSyncUtils {
|
||||||
syncDir: DocumentFile,
|
syncDir: DocumentFile,
|
||||||
bookId: String
|
bookId: String
|
||||||
): FolderBookMetadata? {
|
): FolderBookMetadata? {
|
||||||
|
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||||
val candidates = syncDir.listFiles().filter { file ->
|
val candidates = syncDir.listFiles().filter { file ->
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
val normalizedName = if (name.startsWith(".")) name.substring(1) else name
|
val normalizedName = name.normalizedSidecarName()
|
||||||
normalizedName == "$bookId.json" ||
|
isMetadataSidecarCandidateName(name) &&
|
||||||
normalizedName.startsWith("$bookId.sync-conflict") ||
|
(
|
||||||
normalizedName.startsWith("$bookId.json.sync-conflict")
|
normalizedName.matchesJsonSidecarStem(hashedStem) ||
|
||||||
|
normalizedName.matchesJsonSidecarStem(bookId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (candidates.isEmpty()) return null
|
if (candidates.isEmpty()) return null
|
||||||
return resolveAndCleanConflicts(context, candidates, bookId)
|
return resolveAndCleanConflicts(context, candidates, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAnnotationBookId(name: String?): String? {
|
private fun extractLegacyAnnotationBookId(name: String?): String? {
|
||||||
if (name.isNullOrBlank()) return null
|
if (name.isNullOrBlank()) return null
|
||||||
var temp = name
|
var temp = name
|
||||||
if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null
|
if (!isAnnotationSidecarCandidateName(temp)) return null
|
||||||
if (temp.contains(".sync-conflict")) {
|
if (temp.contains(".sync-conflict")) {
|
||||||
temp = temp.substringBefore(".sync-conflict")
|
temp = temp.substringBefore(".sync-conflict")
|
||||||
}
|
}
|
||||||
|
|
@ -588,9 +552,63 @@ object LocalSyncUtils {
|
||||||
if (temp.startsWith(".")) {
|
if (temp.startsWith(".")) {
|
||||||
temp = temp.substring(1)
|
temp = temp.substring(1)
|
||||||
}
|
}
|
||||||
|
if (temp.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
|
||||||
return temp.ifBlank { null }
|
return temp.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isMetadataSidecarCandidateName(name: String): Boolean {
|
||||||
|
if (name.contains(ANNOTATION_SUFFIX)) return false
|
||||||
|
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
|
||||||
|
return name.endsWith(".json") || name.contains(".sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAnnotationSidecarCandidateName(name: String): Boolean {
|
||||||
|
if (!name.contains(ANNOTATION_SUFFIX)) return false
|
||||||
|
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
|
||||||
|
return name.endsWith(".json") || name.contains(".sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.normalizedSidecarName(): String {
|
||||||
|
return removePrefix(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
|
||||||
|
return this == "$stem.json" ||
|
||||||
|
startsWith("$stem.sync-conflict") ||
|
||||||
|
startsWith("$stem.json.sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueFolderSyncTempName(baseName: String): String {
|
||||||
|
val stem = baseName.removeSuffix(".tmp")
|
||||||
|
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
||||||
|
return "$stem.$nonce.tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAnnotationSidecar(
|
||||||
|
context: Context,
|
||||||
|
file: SyncFileEntry,
|
||||||
|
fallbackBookId: String?
|
||||||
|
): ParsedAnnotationSidecar? {
|
||||||
|
return try {
|
||||||
|
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||||
|
it.bufferedReader().readText()
|
||||||
|
} ?: return null
|
||||||
|
val json = JSONObject(content)
|
||||||
|
val bookId = json.optString("bookId").takeIf { it.isNotBlank() }
|
||||||
|
?: fallbackBookId
|
||||||
|
?: return null
|
||||||
|
val data = json.optJSONObject("data")?.toString() ?: return null
|
||||||
|
ParsedAnnotationSidecar(
|
||||||
|
bookId = bookId,
|
||||||
|
timestamp = json.optLong("timestamp", 0L),
|
||||||
|
data = data
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: ${file.name}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun deleteBookSidecars(
|
suspend fun deleteBookSidecars(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceFolderUri: Uri,
|
sourceFolderUri: Uri,
|
||||||
|
|
@ -599,13 +617,15 @@ object LocalSyncUtils {
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
|
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
|
||||||
|
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||||
|
val hashedAnnotationStem = "$hashedStem$ANNOTATION_SUFFIX"
|
||||||
val targets = syncDir.listFiles().filter { file ->
|
val targets = syncDir.listFiles().filter { file ->
|
||||||
val name = file.name ?: return@filter false
|
val name = file.name ?: return@filter false
|
||||||
val normalized = if (name.startsWith(".")) name.substring(1) else name
|
val normalized = name.normalizedSidecarName()
|
||||||
normalized == "$bookId.json" ||
|
normalized.matchesJsonSidecarStem(hashedStem) ||
|
||||||
normalized.startsWith("$bookId.sync-conflict") ||
|
normalized.matchesJsonSidecarStem(bookId) ||
|
||||||
normalized.startsWith("$bookId.json.sync-conflict") ||
|
normalized.matchesJsonSidecarStem(hashedAnnotationStem) ||
|
||||||
normalized.startsWith("$bookId${ANNOTATION_SUFFIX}")
|
normalized.matchesJsonSidecarStem("$bookId$ANNOTATION_SUFFIX")
|
||||||
}
|
}
|
||||||
targets.forEach {
|
targets.forEach {
|
||||||
try {
|
try {
|
||||||
|
|
@ -626,34 +646,32 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||||
val groupedFiles = allFiles
|
val groupedMetadata = allFiles
|
||||||
.filter {
|
.asSequence()
|
||||||
val name = it.name
|
.filter { isMetadataSidecarCandidateName(it.name) }
|
||||||
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
.mapNotNull { file ->
|
||||||
!name.contains(ANNOTATION_SUFFIX) &&
|
try {
|
||||||
!name.endsWith(".tmp") &&
|
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
|
||||||
!name.contains(".syncthing.")
|
input.bufferedReader().use { it.readText() }
|
||||||
}
|
}
|
||||||
.groupBy { file ->
|
jsonString?.let(FolderBookMetadata::fromJsonString)
|
||||||
var name = file.name
|
} catch (e: Exception) {
|
||||||
if (name.startsWith(".")) name = name.substring(1)
|
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
|
||||||
if (name.contains(".sync-conflict")) {
|
null
|
||||||
name.substringBefore(".sync-conflict")
|
|
||||||
} else {
|
|
||||||
name.substringBefore(".json")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.groupBy { it.bookId }
|
||||||
|
|
||||||
groupedFiles.forEach { (bookId, files) ->
|
groupedMetadata.forEach { (bookId, metadataRecords) ->
|
||||||
val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
|
val winner = metadataRecords.maxByOrNull { it.lastModifiedTimestamp }
|
||||||
if (winner != null) {
|
if (winner != null) {
|
||||||
finalResults[bookId] = winner
|
finalResults[bookId] = winner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.")
|
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedMetadata.size} book records from sync data.")
|
||||||
ReaderPerfLog.d(
|
ReaderPerfLog.d(
|
||||||
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}"
|
"LocalSync metadata read files=${allFiles.size} groups=${groupedMetadata.size} records=${finalResults.size}"
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -663,29 +681,4 @@ object LocalSyncUtils {
|
||||||
return@withContext finalResults
|
return@withContext finalResults
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveMetadataConflictsReadOnly(
|
|
||||||
context: Context,
|
|
||||||
files: List<SyncFileEntry>,
|
|
||||||
bookId: String
|
|
||||||
): FolderBookMetadata? {
|
|
||||||
var bestMeta: FolderBookMetadata? = null
|
|
||||||
for (file in files) {
|
|
||||||
try {
|
|
||||||
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
|
|
||||||
input.bufferedReader().use { it.readText() }
|
|
||||||
}
|
|
||||||
if (jsonString != null) {
|
|
||||||
val meta = FolderBookMetadata.fromJsonString(jsonString)
|
|
||||||
if (meta.bookId == bookId &&
|
|
||||||
(bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp)
|
|
||||||
) {
|
|
||||||
bestMeta = meta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestMeta
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ data class PurchaseEntity(
|
||||||
val purchaseToken: String,
|
val purchaseToken: String,
|
||||||
val purchaseTime: Long,
|
val purchaseTime: Long,
|
||||||
val isAcknowledged: Boolean,
|
val isAcknowledged: Boolean,
|
||||||
val isAutoRenewing: Boolean
|
val isAutoRenewing: Boolean,
|
||||||
|
val obfuscatedAccountId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,4 +42,4 @@ data class ProductDetailsEntity(
|
||||||
val formattedPrice: String,
|
val formattedPrice: String,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val priceAmountMicros: Long
|
val priceAmountMicros: Long
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface RecentFileDao {
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||||
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
||||||
|
|
||||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||||
|
|
@ -45,7 +45,7 @@ interface RecentFileDao {
|
||||||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||||
|
|
||||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||||
|
|
||||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||||
|
|
@ -94,26 +94,36 @@ interface RecentFileDao {
|
||||||
SELECT * FROM recent_files
|
SELECT * FROM recent_files
|
||||||
WHERE sourceFolderUri IS NOT NULL
|
WHERE sourceFolderUri IS NOT NULL
|
||||||
AND isDeleted = 0
|
AND isDeleted = 0
|
||||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
AND (
|
||||||
AND folderTextMetadataParsed = 0
|
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||||
|
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||||
|
)
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity>
|
suspend fun getFolderBooksNeedingTextMetadata(limit: Int): List<RecentFileEntity>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT * FROM recent_files
|
SELECT * FROM recent_files
|
||||||
WHERE sourceFolderUri = :sourceFolderUri
|
WHERE sourceFolderUri = :sourceFolderUri
|
||||||
AND isDeleted = 0
|
AND isDeleted = 0
|
||||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
AND (
|
||||||
AND folderTextMetadataParsed = 0
|
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||||
|
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||||
|
)
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity>
|
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String, limit: Int): List<RecentFileEntity>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT COUNT(*) FROM recent_files
|
SELECT COUNT(*) FROM recent_files
|
||||||
WHERE sourceFolderUri IS NOT NULL
|
WHERE sourceFolderUri IS NOT NULL
|
||||||
AND isDeleted = 0
|
AND isDeleted = 0
|
||||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
AND (
|
||||||
AND folderTextMetadataParsed = 0
|
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||||
|
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||||
|
)
|
||||||
""")
|
""")
|
||||||
suspend fun countFolderBooksNeedingTextMetadata(): Int
|
suspend fun countFolderBooksNeedingTextMetadata(): Int
|
||||||
|
|
||||||
|
|
@ -121,8 +131,10 @@ interface RecentFileDao {
|
||||||
SELECT COUNT(*) FROM recent_files
|
SELECT COUNT(*) FROM recent_files
|
||||||
WHERE sourceFolderUri = :sourceFolderUri
|
WHERE sourceFolderUri = :sourceFolderUri
|
||||||
AND isDeleted = 0
|
AND isDeleted = 0
|
||||||
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
AND (
|
||||||
AND folderTextMetadataParsed = 0
|
(type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
|
||||||
|
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
|
||||||
|
)
|
||||||
""")
|
""")
|
||||||
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
|
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
|
||||||
|
|
||||||
|
|
@ -130,10 +142,60 @@ interface RecentFileDao {
|
||||||
UPDATE recent_files
|
UPDATE recent_files
|
||||||
SET
|
SET
|
||||||
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
|
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
|
||||||
title = COALESCE(:title, title),
|
title = CASE
|
||||||
author = COALESCE(:author, author),
|
WHEN :title IS NOT NULL AND (originalTitle IS NULL OR title IS NULL OR title = originalTitle OR title = displayName)
|
||||||
|
THEN :title
|
||||||
|
ELSE title
|
||||||
|
END,
|
||||||
|
author = CASE
|
||||||
|
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR author IS NULL OR author = originalAuthor)
|
||||||
|
THEN :author
|
||||||
|
ELSE author
|
||||||
|
END,
|
||||||
|
seriesName = CASE
|
||||||
|
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR seriesName IS NULL OR seriesName = originalSeriesName)
|
||||||
|
THEN :seriesName
|
||||||
|
ELSE seriesName
|
||||||
|
END,
|
||||||
|
seriesIndex = CASE
|
||||||
|
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR seriesIndex IS NULL OR seriesIndex = originalSeriesIndex)
|
||||||
|
THEN :seriesIndex
|
||||||
|
ELSE seriesIndex
|
||||||
|
END,
|
||||||
|
description = CASE
|
||||||
|
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR description IS NULL OR description = originalDescription)
|
||||||
|
THEN :description
|
||||||
|
ELSE description
|
||||||
|
END,
|
||||||
|
originalTitle = CASE
|
||||||
|
WHEN :title IS NOT NULL AND (originalTitle IS NULL OR originalTitle = title OR originalTitle = displayName)
|
||||||
|
THEN :title
|
||||||
|
ELSE originalTitle
|
||||||
|
END,
|
||||||
|
originalAuthor = CASE
|
||||||
|
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR originalAuthor = author)
|
||||||
|
THEN :author
|
||||||
|
ELSE originalAuthor
|
||||||
|
END,
|
||||||
|
originalSeriesName = CASE
|
||||||
|
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR originalSeriesName = seriesName)
|
||||||
|
THEN :seriesName
|
||||||
|
ELSE originalSeriesName
|
||||||
|
END,
|
||||||
|
originalSeriesIndex = CASE
|
||||||
|
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR originalSeriesIndex = seriesIndex)
|
||||||
|
THEN :seriesIndex
|
||||||
|
ELSE originalSeriesIndex
|
||||||
|
END,
|
||||||
|
originalDescription = CASE
|
||||||
|
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR originalDescription = description)
|
||||||
|
THEN :description
|
||||||
|
ELSE originalDescription
|
||||||
|
END,
|
||||||
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||||
folderTextMetadataParsed = 1
|
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||||
|
folderTextMetadataParsed = CASE WHEN :textMetadataParsed = 1 THEN 1 ELSE folderTextMetadataParsed END,
|
||||||
|
folderCoverMetadataParsed = CASE WHEN :coverMetadataParsed = 1 THEN 1 ELSE folderCoverMetadataParsed END
|
||||||
WHERE bookId = :bookId
|
WHERE bookId = :bookId
|
||||||
""")
|
""")
|
||||||
suspend fun updateExtractedMetadata(
|
suspend fun updateExtractedMetadata(
|
||||||
|
|
@ -141,7 +203,13 @@ interface RecentFileDao {
|
||||||
coverImagePath: String?,
|
coverImagePath: String?,
|
||||||
title: String?,
|
title: String?,
|
||||||
author: String?,
|
author: String?,
|
||||||
fileSize: Long
|
seriesName: String?,
|
||||||
|
seriesIndex: Double?,
|
||||||
|
description: String?,
|
||||||
|
fileSize: Long,
|
||||||
|
fileContentModifiedTimestamp: Long,
|
||||||
|
textMetadataParsed: Boolean,
|
||||||
|
coverMetadataParsed: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
|
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
|
||||||
|
|
@ -149,4 +217,58 @@ interface RecentFileDao {
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||||
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
|
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE recent_files
|
||||||
|
SET
|
||||||
|
title = :title,
|
||||||
|
author = :author,
|
||||||
|
seriesName = :seriesName,
|
||||||
|
seriesIndex = :seriesIndex,
|
||||||
|
description = :description,
|
||||||
|
customName = NULL,
|
||||||
|
originalTitle = COALESCE(originalTitle, title),
|
||||||
|
originalAuthor = COALESCE(originalAuthor, author),
|
||||||
|
originalSeriesName = COALESCE(originalSeriesName, seriesName),
|
||||||
|
originalSeriesIndex = COALESCE(originalSeriesIndex, seriesIndex),
|
||||||
|
originalDescription = COALESCE(originalDescription, description),
|
||||||
|
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||||
|
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||||
|
folderTextMetadataParsed = 1,
|
||||||
|
lastModifiedTimestamp = :timestamp
|
||||||
|
WHERE bookId = :bookId
|
||||||
|
""")
|
||||||
|
suspend fun updateUserEditableMetadata(
|
||||||
|
bookId: String,
|
||||||
|
title: String?,
|
||||||
|
author: String?,
|
||||||
|
seriesName: String?,
|
||||||
|
seriesIndex: Double?,
|
||||||
|
description: String?,
|
||||||
|
fileSize: Long,
|
||||||
|
fileContentModifiedTimestamp: Long,
|
||||||
|
timestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE recent_files
|
||||||
|
SET
|
||||||
|
title = COALESCE(originalTitle, displayName),
|
||||||
|
author = originalAuthor,
|
||||||
|
seriesName = originalSeriesName,
|
||||||
|
seriesIndex = originalSeriesIndex,
|
||||||
|
description = originalDescription,
|
||||||
|
customName = NULL,
|
||||||
|
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||||
|
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
|
||||||
|
folderTextMetadataParsed = 1,
|
||||||
|
lastModifiedTimestamp = :timestamp
|
||||||
|
WHERE bookId = :bookId
|
||||||
|
""")
|
||||||
|
suspend fun restoreOriginalMetadata(
|
||||||
|
bookId: String,
|
||||||
|
fileSize: Long,
|
||||||
|
fileContentModifiedTimestamp: Long,
|
||||||
|
timestamp: Long
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,17 @@ data class RecentFileEntity(
|
||||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
|
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
|
||||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||||
|
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||||
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
|
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean,
|
||||||
|
@ColumnInfo(defaultValue = "0") val folderCoverMetadataParsed: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentFileSummary(
|
data class RecentFileSummary(
|
||||||
|
|
@ -81,7 +88,13 @@ data class RecentFileSummary(
|
||||||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||||
|
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,17 @@ data class RecentFileItem(
|
||||||
val customName: String? = null,
|
val customName: String? = null,
|
||||||
val highlightsJson: String? = null,
|
val highlightsJson: String? = null,
|
||||||
val fileSize: Long = 0L,
|
val fileSize: Long = 0L,
|
||||||
|
val fileContentModifiedTimestamp: Long = 0L,
|
||||||
val seriesName: String? = null,
|
val seriesName: String? = null,
|
||||||
val seriesIndex: Double? = null,
|
val seriesIndex: Double? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
val originalTitle: String? = null,
|
||||||
|
val originalAuthor: String? = null,
|
||||||
|
val originalSeriesName: String? = null,
|
||||||
|
val originalSeriesIndex: Double? = null,
|
||||||
|
val originalDescription: String? = null,
|
||||||
val folderTextMetadataParsed: Boolean = false,
|
val folderTextMetadataParsed: Boolean = false,
|
||||||
|
val folderCoverMetadataParsed: Boolean = false,
|
||||||
val tags: List<TagEntity> = emptyList()
|
val tags: List<TagEntity> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -79,10 +86,17 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = this.highlights,
|
highlightsJson = this.highlights,
|
||||||
fileSize = this.fileSize,
|
fileSize = this.fileSize,
|
||||||
|
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||||
seriesName = this.seriesName,
|
seriesName = this.seriesName,
|
||||||
seriesIndex = this.seriesIndex,
|
seriesIndex = this.seriesIndex,
|
||||||
description = this.description,
|
description = this.description,
|
||||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
originalTitle = this.originalTitle,
|
||||||
|
originalAuthor = this.originalAuthor,
|
||||||
|
originalSeriesName = this.originalSeriesName,
|
||||||
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
|
originalDescription = this.originalDescription,
|
||||||
|
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,10 +126,17 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlights = this.highlightsJson,
|
highlights = this.highlightsJson,
|
||||||
fileSize = this.fileSize,
|
fileSize = this.fileSize,
|
||||||
|
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||||
seriesName = this.seriesName,
|
seriesName = this.seriesName,
|
||||||
seriesIndex = this.seriesIndex,
|
seriesIndex = this.seriesIndex,
|
||||||
description = this.description,
|
description = this.description,
|
||||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
originalTitle = this.originalTitle ?: this.title,
|
||||||
|
originalAuthor = this.originalAuthor ?: this.author,
|
||||||
|
originalSeriesName = this.originalSeriesName ?: this.seriesName,
|
||||||
|
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
||||||
|
originalDescription = this.originalDescription ?: this.description,
|
||||||
|
folderTextMetadataParsed = this.folderTextMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = this.folderCoverMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +159,16 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
hasAnnotations = false,
|
hasAnnotations = false,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = this.highlightsJson
|
highlightsJson = this.highlightsJson,
|
||||||
|
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||||
|
seriesName = this.seriesName,
|
||||||
|
seriesIndex = this.seriesIndex,
|
||||||
|
description = this.description,
|
||||||
|
originalTitle = this.originalTitle ?: this.title,
|
||||||
|
originalAuthor = this.originalAuthor ?: this.author,
|
||||||
|
originalSeriesName = this.originalSeriesName ?: this.seriesName,
|
||||||
|
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
|
||||||
|
originalDescription = this.originalDescription ?: this.description
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,7 +194,16 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = this.highlightsJson
|
highlightsJson = this.highlightsJson,
|
||||||
|
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||||
|
seriesName = this.seriesName,
|
||||||
|
seriesIndex = this.seriesIndex,
|
||||||
|
description = this.description,
|
||||||
|
originalTitle = this.originalTitle,
|
||||||
|
originalAuthor = this.originalAuthor,
|
||||||
|
originalSeriesName = this.originalSeriesName,
|
||||||
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
|
originalDescription = this.originalDescription
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +233,14 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = null,
|
highlightsJson = null,
|
||||||
fileSize = this.fileSize,
|
fileSize = this.fileSize,
|
||||||
|
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
|
||||||
seriesName = this.seriesName,
|
seriesName = this.seriesName,
|
||||||
seriesIndex = this.seriesIndex,
|
seriesIndex = this.seriesIndex,
|
||||||
description = this.description
|
description = this.description,
|
||||||
|
originalTitle = this.originalTitle,
|
||||||
|
originalAuthor = this.originalAuthor,
|
||||||
|
originalSeriesName = this.originalSeriesName,
|
||||||
|
originalSeriesIndex = this.originalSeriesIndex,
|
||||||
|
originalDescription = this.originalDescription
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,12 @@ package com.aryan.reader.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.ReaderPerfLog
|
import com.aryan.reader.ReaderPerfLog
|
||||||
|
import com.aryan.reader.scaledToCanvasLimit
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.aryan.reader.BookImporter
|
import com.aryan.reader.BookImporter
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
|
|
@ -40,7 +43,6 @@ import java.io.FileOutputStream
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
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 com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
|
@ -48,6 +50,9 @@ import java.util.UUID
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
|
||||||
private const val COVER_CACHE_DIR = "cover_cache"
|
private const val COVER_CACHE_DIR = "cover_cache"
|
||||||
|
private const val DIRECT_EMBEDDED_COVER_MAX_BYTES = 8L * 1024L * 1024L
|
||||||
|
private const val EMBEDDED_COVER_MAX_DIMENSION = 1200
|
||||||
|
private val EMBEDDED_COVER_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
class RecentFilesRepository(private val context: Context) {
|
class RecentFilesRepository(private val context: Context) {
|
||||||
|
|
||||||
|
|
@ -169,12 +174,43 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val entityToInsert = if (existingItem != null) {
|
val entityToInsert = if (existingItem != null) {
|
||||||
|
val folderFileChanged = item.sourceFolderUri != null &&
|
||||||
|
existingItem.sourceFolderUri == item.sourceFolderUri &&
|
||||||
|
((item.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
|
||||||
|
(item.fileContentModifiedTimestamp > 0L &&
|
||||||
|
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp))
|
||||||
|
val embeddedMetadataFileChanged =
|
||||||
|
(item.fileSize > 0L && existingItem.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
|
||||||
|
(item.fileContentModifiedTimestamp > 0L &&
|
||||||
|
existingItem.fileContentModifiedTimestamp > 0L &&
|
||||||
|
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp)
|
||||||
|
val keepExistingEmbeddedMetadata = item.type == FileType.EPUB &&
|
||||||
|
existingItem.type == FileType.EPUB &&
|
||||||
|
!embeddedMetadataFileChanged &&
|
||||||
|
existingItem.hasEmbeddedMetadataChanges()
|
||||||
|
|
||||||
item.toRecentFileEntity().copy(
|
item.toRecentFileEntity().copy(
|
||||||
uriString = existingItem.uriString ?: item.uriString,
|
uriString = existingItem.uriString ?: item.uriString,
|
||||||
isAvailable = existingItem.isAvailable || item.isAvailable,
|
isAvailable = existingItem.isAvailable || item.isAvailable,
|
||||||
coverImagePath = item.coverImagePath ?: existingItem.coverImagePath,
|
coverImagePath = if (folderFileChanged) {
|
||||||
title = item.title ?: existingItem.title,
|
item.coverImagePath
|
||||||
author = item.author ?: existingItem.author,
|
} else {
|
||||||
|
item.coverImagePath ?: existingItem.coverImagePath
|
||||||
|
},
|
||||||
|
title = if (folderFileChanged) {
|
||||||
|
item.title ?: item.displayName.substringBeforeLast('.', item.displayName)
|
||||||
|
} else if (keepExistingEmbeddedMetadata) {
|
||||||
|
existingItem.title
|
||||||
|
} else {
|
||||||
|
item.title ?: existingItem.title
|
||||||
|
},
|
||||||
|
author = if (folderFileChanged) {
|
||||||
|
item.author
|
||||||
|
} else if (keepExistingEmbeddedMetadata) {
|
||||||
|
existingItem.author
|
||||||
|
} else {
|
||||||
|
item.author ?: existingItem.author
|
||||||
|
},
|
||||||
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
|
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
|
||||||
lastPage = item.lastPage ?: existingItem.lastPage,
|
lastPage = item.lastPage ?: existingItem.lastPage,
|
||||||
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
|
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
|
||||||
|
|
@ -187,10 +223,43 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
||||||
highlights = item.highlightsJson ?: existingItem.highlights,
|
highlights = item.highlightsJson ?: existingItem.highlights,
|
||||||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
||||||
seriesName = item.seriesName ?: existingItem.seriesName,
|
fileContentModifiedTimestamp = if (item.fileContentModifiedTimestamp > 0) item.fileContentModifiedTimestamp else existingItem.fileContentModifiedTimestamp,
|
||||||
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
seriesName = if (folderFileChanged) {
|
||||||
description = item.description ?: existingItem.description,
|
item.seriesName
|
||||||
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
} else if (keepExistingEmbeddedMetadata) {
|
||||||
|
existingItem.seriesName
|
||||||
|
} else {
|
||||||
|
item.seriesName ?: existingItem.seriesName
|
||||||
|
},
|
||||||
|
seriesIndex = if (folderFileChanged) {
|
||||||
|
item.seriesIndex
|
||||||
|
} else if (keepExistingEmbeddedMetadata) {
|
||||||
|
existingItem.seriesIndex
|
||||||
|
} else {
|
||||||
|
item.seriesIndex ?: existingItem.seriesIndex
|
||||||
|
},
|
||||||
|
description = if (folderFileChanged) {
|
||||||
|
item.description
|
||||||
|
} else if (keepExistingEmbeddedMetadata) {
|
||||||
|
existingItem.description
|
||||||
|
} else {
|
||||||
|
item.description ?: existingItem.description
|
||||||
|
},
|
||||||
|
originalTitle = if (folderFileChanged) item.originalTitle ?: item.title else existingItem.originalTitle ?: item.originalTitle ?: item.title,
|
||||||
|
originalAuthor = if (folderFileChanged) item.originalAuthor ?: item.author else existingItem.originalAuthor ?: item.originalAuthor ?: item.author,
|
||||||
|
originalSeriesName = if (folderFileChanged) item.originalSeriesName ?: item.seriesName else existingItem.originalSeriesName ?: item.originalSeriesName ?: item.seriesName,
|
||||||
|
originalSeriesIndex = if (folderFileChanged) item.originalSeriesIndex ?: item.seriesIndex else existingItem.originalSeriesIndex ?: item.originalSeriesIndex ?: item.seriesIndex,
|
||||||
|
originalDescription = if (folderFileChanged) item.originalDescription ?: item.description else existingItem.originalDescription ?: item.originalDescription ?: item.description,
|
||||||
|
folderTextMetadataParsed = if (folderFileChanged) {
|
||||||
|
item.folderTextMetadataParsed
|
||||||
|
} else {
|
||||||
|
item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
||||||
|
},
|
||||||
|
folderCoverMetadataParsed = if (folderFileChanged) {
|
||||||
|
item.folderCoverMetadataParsed
|
||||||
|
} else {
|
||||||
|
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
item.toRecentFileEntity()
|
item.toRecentFileEntity()
|
||||||
|
|
@ -201,13 +270,60 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RecentFileEntity.hasEmbeddedMetadataChanges(): Boolean {
|
||||||
|
val hasOriginalMetadata = listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription)
|
||||||
|
.any { !it.isNullOrBlank() } || originalSeriesIndex != null
|
||||||
|
return (hasOriginalMetadata && (
|
||||||
|
metadataValueChanged(title, originalTitle) ||
|
||||||
|
metadataValueChanged(author, originalAuthor) ||
|
||||||
|
metadataValueChanged(seriesName, originalSeriesName) ||
|
||||||
|
seriesIndex != originalSeriesIndex ||
|
||||||
|
metadataValueChanged(description, originalDescription)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metadataValueChanged(current: String?, original: String?): Boolean {
|
||||||
|
return current.orEmpty().trim() != original.orEmpty().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateUserEditableMetadata(
|
||||||
|
bookId: String,
|
||||||
|
metadata: BookMetadataEdit,
|
||||||
|
fileSize: Long = 0L,
|
||||||
|
fileContentModifiedTimestamp: Long = 0L
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
recentFileDao.updateUserEditableMetadata(
|
||||||
|
bookId = bookId,
|
||||||
|
title = metadata.title,
|
||||||
|
author = metadata.author,
|
||||||
|
seriesName = metadata.seriesName,
|
||||||
|
seriesIndex = metadata.seriesIndex,
|
||||||
|
description = metadata.description,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
timestamp = currentTime
|
||||||
|
)
|
||||||
|
Timber.d("Updated user-editable metadata for $bookId")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreOriginalMetadata(
|
||||||
|
bookId: String,
|
||||||
|
fileSize: Long = 0L,
|
||||||
|
fileContentModifiedTimestamp: Long = 0L
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
recentFileDao.restoreOriginalMetadata(bookId, fileSize, fileContentModifiedTimestamp, currentTime)
|
||||||
|
Timber.d("Restored original metadata for $bookId")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) {
|
suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
recentFileDao.updateHighlights(bookId, highlightsJson, currentTime)
|
recentFileDao.updateHighlights(bookId, highlightsJson, currentTime)
|
||||||
Timber.d("Updated highlights for $bookId")
|
Timber.d("Updated highlights for $bookId")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) {
|
suspend fun syncLocalMetadataToFolder(bookId: String, force: Boolean = false) = withContext(Dispatchers.IO) {
|
||||||
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
|
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
|
||||||
val folderUriString = entity.sourceFolderUri
|
val folderUriString = entity.sourceFolderUri
|
||||||
|
|
||||||
|
|
@ -217,7 +333,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
|
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
|
||||||
val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights
|
val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights
|
||||||
|
|
||||||
if (!isDirty) {
|
if (!force && !isDirty) {
|
||||||
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
|
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +356,15 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
locatorBlockIndex = entity.locatorBlockIndex,
|
locatorBlockIndex = entity.locatorBlockIndex,
|
||||||
locatorCharOffset = entity.locatorCharOffset,
|
locatorCharOffset = entity.locatorCharOffset,
|
||||||
customName = entity.customName,
|
customName = entity.customName,
|
||||||
highlightsJson = entity.highlights
|
highlightsJson = entity.highlights,
|
||||||
|
seriesName = entity.seriesName,
|
||||||
|
seriesIndex = entity.seriesIndex,
|
||||||
|
description = entity.description,
|
||||||
|
originalTitle = entity.originalTitle,
|
||||||
|
originalAuthor = entity.originalAuthor,
|
||||||
|
originalSeriesName = entity.originalSeriesName,
|
||||||
|
originalSeriesIndex = entity.originalSeriesIndex,
|
||||||
|
originalDescription = entity.originalDescription
|
||||||
)
|
)
|
||||||
|
|
||||||
LocalSyncUtils.saveMetadataToFolder(
|
LocalSyncUtils.saveMetadataToFolder(
|
||||||
|
|
@ -275,7 +399,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val hasHighlights = highlightFile.exists()
|
val hasHighlights = highlightFile.exists()
|
||||||
|
|
||||||
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
|
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
|
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
|
||||||
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
|
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
|
||||||
)
|
)
|
||||||
|
|
@ -291,7 +415,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
try {
|
try {
|
||||||
val content = file.readText().trim()
|
val content = file.readText().trim()
|
||||||
if (key == "text") {
|
if (key == "text") {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
|
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +426,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (key == "text") {
|
if (key == "text") {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
|
Timber
|
||||||
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
|
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
|
||||||
}
|
}
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
|
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
|
||||||
|
|
@ -328,7 +452,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
|
|
||||||
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
|
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
|
||||||
if (hasRichText) {
|
if (hasRichText) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
|
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +472,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val bundle = JSONObject(
|
val bundle = JSONObject(
|
||||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
|
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
|
||||||
)
|
)
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
|
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
|
||||||
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
|
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
|
||||||
)
|
)
|
||||||
|
|
@ -359,7 +483,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val contentStr = bundle.get(key).toString()
|
val contentStr = bundle.get(key).toString()
|
||||||
file.writeText(contentStr)
|
file.writeText(contentStr)
|
||||||
if (key == "text") {
|
if (key == "text") {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
|
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -431,11 +555,15 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
suspend fun getFolderBooksNeedingTextMetadata(
|
||||||
|
sourceFolderUri: String? = null,
|
||||||
|
limit: Int = Int.MAX_VALUE
|
||||||
|
): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||||
|
val queryLimit = limit.coerceAtLeast(1)
|
||||||
val entities = if (sourceFolderUri.isNullOrBlank()) {
|
val entities = if (sourceFolderUri.isNullOrBlank()) {
|
||||||
recentFileDao.getFolderBooksNeedingTextMetadata()
|
recentFileDao.getFolderBooksNeedingTextMetadata(queryLimit)
|
||||||
} else {
|
} else {
|
||||||
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri, queryLimit)
|
||||||
}
|
}
|
||||||
return@withContext entities.map { it.toRecentFileItem() }
|
return@withContext entities.map { it.toRecentFileItem() }
|
||||||
}
|
}
|
||||||
|
|
@ -459,7 +587,13 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
coverImagePath = item.coverImagePath,
|
coverImagePath = item.coverImagePath,
|
||||||
title = item.title,
|
title = item.title,
|
||||||
author = item.author,
|
author = item.author,
|
||||||
fileSize = item.fileSize
|
seriesName = item.seriesName,
|
||||||
|
seriesIndex = item.seriesIndex,
|
||||||
|
description = item.description,
|
||||||
|
fileSize = item.fileSize,
|
||||||
|
fileContentModifiedTimestamp = item.fileContentModifiedTimestamp,
|
||||||
|
textMetadataParsed = item.folderTextMetadataParsed,
|
||||||
|
coverMetadataParsed = item.folderCoverMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -562,9 +696,17 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val filename = "cover_${uri.toString().hashCode()}.png"
|
val filename = "cover_${uri.toString().hashCode()}.png"
|
||||||
val file = File(cacheDir, filename)
|
val file = File(cacheDir, filename)
|
||||||
var fos: FileOutputStream? = null
|
var fos: FileOutputStream? = null
|
||||||
|
var scaledCopy: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
|
deleteCoverCacheVariants(uri)
|
||||||
|
val bitmapToSave = bitmap.scaledToCanvasLimit(
|
||||||
|
maxBytes = 8L * 1024L * 1024L,
|
||||||
|
maxDimension = EMBEDDED_COVER_MAX_DIMENSION
|
||||||
|
).also {
|
||||||
|
if (it !== bitmap) scaledCopy = it
|
||||||
|
}
|
||||||
fos = FileOutputStream(file)
|
fos = FileOutputStream(file)
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
|
bitmapToSave.compress(Bitmap.CompressFormat.PNG, 90, fos)
|
||||||
Timber.d("Saved cover image to: ${file.absolutePath}")
|
Timber.d("Saved cover image to: ${file.absolutePath}")
|
||||||
return@withContext file.absolutePath
|
return@withContext file.absolutePath
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -573,9 +715,70 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
} finally {
|
} finally {
|
||||||
fos?.close()
|
fos?.close()
|
||||||
|
scaledCopy?.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun saveEmbeddedCoverToCache(bytes: ByteArray, uri: Uri, extension: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
if (bytes.isEmpty()) return@withContext null
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
|
||||||
|
|
||||||
|
val safeExtension = extension.lowercase().takeIf { it in EMBEDDED_COVER_EXTENSIONS } ?: "png"
|
||||||
|
if (
|
||||||
|
bytes.size.toLong() <= DIRECT_EMBEDDED_COVER_MAX_BYTES &&
|
||||||
|
bounds.outWidth <= EMBEDDED_COVER_MAX_DIMENSION &&
|
||||||
|
bounds.outHeight <= EMBEDDED_COVER_MAX_DIMENSION
|
||||||
|
) {
|
||||||
|
return@withContext saveEmbeddedCoverBytesToCache(bytes, uri, safeExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampleSize = 1
|
||||||
|
while (
|
||||||
|
(bounds.outWidth / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION ||
|
||||||
|
(bounds.outHeight / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION
|
||||||
|
) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(
|
||||||
|
bytes,
|
||||||
|
0,
|
||||||
|
bytes.size,
|
||||||
|
BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
||||||
|
) ?: return@withContext null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return@withContext saveCoverToCache(decoded, uri)
|
||||||
|
} finally {
|
||||||
|
decoded.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEmbeddedCoverBytesToCache(bytes: ByteArray, uri: Uri, extension: String): String? {
|
||||||
|
val cacheDir = getCoverCacheDirInternal()
|
||||||
|
val filename = "cover_${uri.toString().hashCode()}.$extension"
|
||||||
|
val file = File(cacheDir, filename)
|
||||||
|
return try {
|
||||||
|
deleteCoverCacheVariants(uri)
|
||||||
|
FileOutputStream(file).use { output -> output.write(bytes) }
|
||||||
|
Timber.d("Saved embedded cover image to: ${file.absolutePath}")
|
||||||
|
file.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to save embedded cover image to cache for $uri")
|
||||||
|
file.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteCoverCacheVariants(uri: Uri) {
|
||||||
|
val prefix = "cover_${uri.toString().hashCode()}."
|
||||||
|
getCoverCacheDirInternal().listFiles()
|
||||||
|
?.filter { it.isFile && it.name.startsWith(prefix) }
|
||||||
|
?.forEach { runCatching { it.delete() } }
|
||||||
|
}
|
||||||
|
|
||||||
private fun deleteCachedCover(filePath: String): Boolean {
|
private fun deleteCachedCover(filePath: String): Boolean {
|
||||||
val file = File(filePath)
|
val file = File(filePath)
|
||||||
val deleted = file.delete()
|
val deleted = file.delete()
|
||||||
|
|
@ -622,10 +825,11 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
|
|
||||||
suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) {
|
suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
recentFileDao.getFileByBookId(bookId)?.coverImagePath?.let { deleteCachedCover(it) }
|
||||||
pdfRichTextRepository.getFileForSync(bookId).delete()
|
pdfRichTextRepository.getFileForSync(bookId).delete()
|
||||||
pageLayoutRepository.getLayoutFile(bookId).delete()
|
pageLayoutRepository.getLayoutFile(bookId).delete()
|
||||||
ImportedFileCache.clearBookCache(context, bookId)
|
ImportedFileCache.clearBookCache(context, bookId)
|
||||||
Timber.d("Cleared layout and text caches for modified book: $bookId")
|
Timber.d("Cleared local caches for modified book: $bookId")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error clearing caches for $bookId")
|
Timber.e(e, "Error clearing caches for $bookId")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,11 @@ import org.w3c.dom.Node
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -78,7 +80,8 @@ class EpubParser(private val context: Context) {
|
||||||
val originalBookNameHint: String,
|
val originalBookNameHint: String,
|
||||||
val parserVersion: Int,
|
val parserVersion: Int,
|
||||||
val parseContent: Boolean,
|
val parseContent: Boolean,
|
||||||
val shouldUseToc: Boolean
|
val shouldUseToc: Boolean,
|
||||||
|
val sourceFingerprint: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// EpubFile can still represent in-memory file data during initial parsing before extraction
|
// EpubFile can still represent in-memory file data during initial parsing before extraction
|
||||||
|
|
@ -109,6 +112,9 @@ class EpubParser(private val context: Context) {
|
||||||
private const val BOOK_METADATA_FILE = "book_metadata.json"
|
private const val BOOK_METADATA_FILE = "book_metadata.json"
|
||||||
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
|
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
|
||||||
private const val EPUB_EXTRACTION_CACHE_VERSION = 1
|
private const val EPUB_EXTRACTION_CACHE_VERSION = 1
|
||||||
|
private const val MAX_METADATA_ENTRY_BYTES = 4 * 1024 * 1024
|
||||||
|
private const val EPUB_COVER_MAX_DIMENSION = 1024
|
||||||
|
private val EPUB_IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val String.decodedURL: String
|
internal val String.decodedURL: String
|
||||||
|
|
@ -185,7 +191,8 @@ class EpubParser(private val context: Context) {
|
||||||
shouldUseToc: Boolean = true,
|
shouldUseToc: Boolean = true,
|
||||||
originalBookNameHint: String = "streamed_book",
|
originalBookNameHint: String = "streamed_book",
|
||||||
parseContent: Boolean = true,
|
parseContent: Boolean = true,
|
||||||
extractionDirOverride: File? = null
|
extractionDirOverride: File? = null,
|
||||||
|
sourceFingerprint: String? = null
|
||||||
): EpubBook {
|
): EpubBook {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
||||||
|
|
@ -201,7 +208,8 @@ class EpubParser(private val context: Context) {
|
||||||
extractionDir = activeDir,
|
extractionDir = activeDir,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
originalBookNameHint = originalBookNameHint,
|
originalBookNameHint = originalBookNameHint,
|
||||||
shouldUseToc = shouldUseToc
|
shouldUseToc = shouldUseToc,
|
||||||
|
sourceFingerprint = sourceFingerprint
|
||||||
)?.let { cachedBook ->
|
)?.let { cachedBook ->
|
||||||
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
|
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
|
||||||
return@withContext cachedBook
|
return@withContext cachedBook
|
||||||
|
|
@ -215,7 +223,12 @@ class EpubParser(private val context: Context) {
|
||||||
tempFile.outputStream().use { output ->
|
tempFile.outputStream().use { output ->
|
||||||
inputStream.copyTo(output)
|
inputStream.copyTo(output)
|
||||||
}
|
}
|
||||||
filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent)
|
filesMap = extractEpubContents(
|
||||||
|
zipFile = ZipFile(tempFile),
|
||||||
|
extractionDir = extractionDir,
|
||||||
|
parseContent = parseContent,
|
||||||
|
extractImagesForMetadata = shouldDeleteExtractionDir
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +242,7 @@ class EpubParser(private val context: Context) {
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
originalBookNameHint = originalBookNameHint,
|
originalBookNameHint = originalBookNameHint,
|
||||||
shouldUseToc = shouldUseToc,
|
shouldUseToc = shouldUseToc,
|
||||||
|
sourceFingerprint = sourceFingerprint,
|
||||||
book = book
|
book = book
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +257,8 @@ class EpubParser(private val context: Context) {
|
||||||
extractionDir: File,
|
extractionDir: File,
|
||||||
bookId: String,
|
bookId: String,
|
||||||
originalBookNameHint: String,
|
originalBookNameHint: String,
|
||||||
shouldUseToc: Boolean
|
shouldUseToc: Boolean,
|
||||||
|
sourceFingerprint: String?
|
||||||
): EpubBook? {
|
): EpubBook? {
|
||||||
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
|
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
|
||||||
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
|
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
|
||||||
|
|
@ -255,7 +270,8 @@ class EpubParser(private val context: Context) {
|
||||||
manifest.originalBookNameHint == originalBookNameHint &&
|
manifest.originalBookNameHint == originalBookNameHint &&
|
||||||
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
|
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
|
||||||
manifest.parseContent &&
|
manifest.parseContent &&
|
||||||
manifest.shouldUseToc == shouldUseToc
|
manifest.shouldUseToc == shouldUseToc &&
|
||||||
|
manifest.sourceFingerprint == sourceFingerprint
|
||||||
|
|
||||||
if (!isCompatible) {
|
if (!isCompatible) {
|
||||||
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
|
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
|
||||||
|
|
@ -277,6 +293,7 @@ class EpubParser(private val context: Context) {
|
||||||
bookId: String,
|
bookId: String,
|
||||||
originalBookNameHint: String,
|
originalBookNameHint: String,
|
||||||
shouldUseToc: Boolean,
|
shouldUseToc: Boolean,
|
||||||
|
sourceFingerprint: String?,
|
||||||
book: EpubBook
|
book: EpubBook
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -288,7 +305,8 @@ class EpubParser(private val context: Context) {
|
||||||
originalBookNameHint = originalBookNameHint,
|
originalBookNameHint = originalBookNameHint,
|
||||||
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
|
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
|
||||||
parseContent = true,
|
parseContent = true,
|
||||||
shouldUseToc = shouldUseToc
|
shouldUseToc = shouldUseToc,
|
||||||
|
sourceFingerprint = sourceFingerprint
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -297,22 +315,40 @@ class EpubParser(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
|
internal fun extractEpubContents(
|
||||||
|
zipFile: ZipFile,
|
||||||
|
extractionDir: File,
|
||||||
|
parseContent: Boolean,
|
||||||
|
extractImagesForMetadata: Boolean
|
||||||
|
): Map<String, EpubFile> {
|
||||||
val filesMap = mutableMapOf<String, EpubFile>()
|
val filesMap = mutableMapOf<String, EpubFile>()
|
||||||
zipFile.use { zf ->
|
zipFile.use { zf ->
|
||||||
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
|
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
|
||||||
val isEssential = isEssentialFile(entry.name, parseContent)
|
val isEssential = isEssentialFile(entry.name, parseContent)
|
||||||
val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE))
|
val isImage = isEpubImageFile(entry.name)
|
||||||
|
|
||||||
if (!parseContent) {
|
if (!parseContent) {
|
||||||
if (isEssential || isImage) {
|
when {
|
||||||
val data = zf.getInputStream(entry).readBytes()
|
isEssential -> {
|
||||||
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
|
val data = zf.readSmallEntryBytes(entry) ?: return@forEach
|
||||||
|
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
|
||||||
|
}
|
||||||
|
isImage && extractImagesForMetadata -> {
|
||||||
|
val outputFile = safeExtractionFile(extractionDir, entry.name)
|
||||||
|
?: return@forEach
|
||||||
|
outputFile.parentFile?.mkdirs()
|
||||||
|
zf.getInputStream(entry).use { input ->
|
||||||
|
FileOutputStream(outputFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesMap[entry.name] = EpubFile(absPath = entry.name, data = ByteArray(0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputFile = File(extractionDir, entry.name)
|
val outputFile = safeExtractionFile(extractionDir, entry.name) ?: return@forEach
|
||||||
outputFile.parentFile?.mkdirs()
|
outputFile.parentFile?.mkdirs()
|
||||||
zf.getInputStream(entry).use { input ->
|
zf.getInputStream(entry).use { input ->
|
||||||
FileOutputStream(outputFile).use { output ->
|
FileOutputStream(outputFile).use { output ->
|
||||||
|
|
@ -329,6 +365,59 @@ class EpubParser(private val context: Context) {
|
||||||
return filesMap
|
return filesMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isEpubImageFile(fileName: String): Boolean {
|
||||||
|
val lowerName = fileName.lowercase()
|
||||||
|
return EPUB_IMAGE_EXTENSIONS.any { lowerName.endsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun safeExtractionFile(extractionDir: File, entryName: String): File? {
|
||||||
|
val outputFile = File(extractionDir, entryName)
|
||||||
|
val root = extractionDir.canonicalFile
|
||||||
|
val target = outputFile.canonicalFile
|
||||||
|
val rootPath = root.path
|
||||||
|
val targetPath = target.path
|
||||||
|
val isInsideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
|
||||||
|
|
||||||
|
if (!isInsideRoot) {
|
||||||
|
Timber.w("Skipping unsafe EPUB entry outside extraction root: $entryName")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readSmallEntryBytes(entry: ZipEntry): ByteArray? {
|
||||||
|
if (entry.size > MAX_METADATA_ENTRY_BYTES.toLong()) {
|
||||||
|
Timber.w("Skipping oversized EPUB metadata entry: ${entry.name} (${entry.size} bytes)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val initialSize = entry.size
|
||||||
|
.takeIf { it in 0..MAX_METADATA_ENTRY_BYTES.toLong() }
|
||||||
|
?.toInt()
|
||||||
|
?: DEFAULT_BUFFER_SIZE
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var totalBytes = 0
|
||||||
|
|
||||||
|
return getInputStream(entry).use { input ->
|
||||||
|
ByteArrayOutputStream(initialSize).use { output ->
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
|
||||||
|
totalBytes += read
|
||||||
|
if (totalBytes > MAX_METADATA_ENTRY_BYTES) {
|
||||||
|
Timber.w("Skipping oversized EPUB metadata entry while reading: ${entry.name}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun parseAndCreateEbook(
|
private suspend fun parseAndCreateEbook(
|
||||||
filesContentMap: Map<String, EpubFile>,
|
filesContentMap: Map<String, EpubFile>,
|
||||||
document: EpubDocument,
|
document: EpubDocument,
|
||||||
|
|
@ -712,8 +801,6 @@ class EpubParser(private val context: Context) {
|
||||||
filesContentMap: Map<String, EpubFile>,
|
filesContentMap: Map<String, EpubFile>,
|
||||||
@Suppress("UNUSED_PARAMETER") extractionRoot: File
|
@Suppress("UNUSED_PARAMETER") extractionRoot: File
|
||||||
): List<EpubImage> {
|
): List<EpubImage> {
|
||||||
val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
|
|
||||||
|
|
||||||
val listedImages = manifestItems.values
|
val listedImages = manifestItems.values
|
||||||
.filter { it.mediaType.startsWith("image/") }
|
.filter { it.mediaType.startsWith("image/") }
|
||||||
.map { manifestItem ->
|
.map { manifestItem ->
|
||||||
|
|
@ -725,7 +812,7 @@ class EpubParser(private val context: Context) {
|
||||||
val unlistedImages = filesContentMap.keys
|
val unlistedImages = filesContentMap.keys
|
||||||
.filter { path ->
|
.filter { path ->
|
||||||
val lowerPath = path.lowercase()
|
val lowerPath = path.lowercase()
|
||||||
imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
|
EPUB_IMAGE_EXTENSIONS.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
|
||||||
}
|
}
|
||||||
.map { path ->
|
.map { path ->
|
||||||
EpubImage(absPath = path)
|
EpubImage(absPath = path)
|
||||||
|
|
@ -743,11 +830,9 @@ class EpubParser(private val context: Context) {
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
val coverManifestItem = manifestItems[metadataCoverId]
|
val coverManifestItem = manifestItems[metadataCoverId]
|
||||||
if (coverManifestItem != null) {
|
if (coverManifestItem != null) {
|
||||||
val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
val coverImage = decodeEpubImage(coverManifestItem.absPath, filesContentMap, extractionRoot)
|
||||||
?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes()
|
if (coverImage != null) {
|
||||||
|
return coverImage
|
||||||
if (coverImageBytes != null) {
|
|
||||||
return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
|
|
||||||
} else {
|
} else {
|
||||||
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
|
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
|
||||||
}
|
}
|
||||||
|
|
@ -768,21 +853,15 @@ class EpubParser(private val context: Context) {
|
||||||
)
|
)
|
||||||
for (path in possiblePaths) {
|
for (path in possiblePaths) {
|
||||||
if (filesContentMap.containsKey(path)) {
|
if (filesContentMap.containsKey(path)) {
|
||||||
val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }
|
decodeEpubImage(path, filesContentMap, extractionRoot)?.let {
|
||||||
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
|
|
||||||
|
|
||||||
bytes?.let {
|
|
||||||
Timber.d("Found fallback cover image at $path")
|
Timber.d("Found fallback cover image at $path")
|
||||||
return BitmapFactory.decodeByteArray(it, 0, it.size)
|
return it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
|
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
|
||||||
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
decodeEpubImage(manifestItem.absPath, filesContentMap, extractionRoot)?.let {
|
||||||
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
|
|
||||||
|
|
||||||
bytes?.let {
|
|
||||||
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
|
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
|
||||||
return BitmapFactory.decodeByteArray(it, 0, it.size)
|
return it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -791,6 +870,53 @@ class EpubParser(private val context: Context) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeEpubImage(
|
||||||
|
path: String,
|
||||||
|
filesContentMap: Map<String, EpubFile>,
|
||||||
|
extractionRoot: File
|
||||||
|
): Bitmap? {
|
||||||
|
filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }?.let { bytes ->
|
||||||
|
return decodeSampledByteArray(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageFile = File(extractionRoot, path).takeIf { it.exists() && it.isFile } ?: return null
|
||||||
|
return decodeSampledFile(imageFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSampledByteArray(bytes: ByteArray): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateBitmapSampleSize(bounds)
|
||||||
|
}
|
||||||
|
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSampledFile(file: File): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateBitmapSampleSize(bounds)
|
||||||
|
}
|
||||||
|
return BitmapFactory.decodeFile(file.absolutePath, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateBitmapSampleSize(options: BitmapFactory.Options): Int {
|
||||||
|
val width = options.outWidth
|
||||||
|
val height = options.outHeight
|
||||||
|
if (width <= 0 || height <= 0) return 1
|
||||||
|
|
||||||
|
var sampleSize = 1
|
||||||
|
while ((width / sampleSize) > EPUB_COVER_MAX_DIMENSION || (height / sampleSize) > EPUB_COVER_MAX_DIMENSION) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
return sampleSize
|
||||||
|
}
|
||||||
|
|
||||||
private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean {
|
private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean {
|
||||||
val lowerName = fileName.lowercase()
|
val lowerName = fileName.lowercase()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
|
@ -90,6 +91,34 @@ import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
|
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||||
|
"PageInfoReporter",
|
||||||
|
"ProgressReporter",
|
||||||
|
"ContentBridge",
|
||||||
|
"HighlightBridge",
|
||||||
|
"AutoScrollBridge",
|
||||||
|
"CfiBridge",
|
||||||
|
"SnippetBridge",
|
||||||
|
"TtsBridge",
|
||||||
|
"AiBridge",
|
||||||
|
"FootnoteBridge",
|
||||||
|
"LinkNavBridge"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun WebView.releaseReaderResources() {
|
||||||
|
try {
|
||||||
|
stopLoading()
|
||||||
|
READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) }
|
||||||
|
webChromeClient = null
|
||||||
|
webViewClient = WebViewClient()
|
||||||
|
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
|
||||||
|
clearHistory()
|
||||||
|
removeAllViews()
|
||||||
|
destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to fully release EPUB WebView resources")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getFontCssInjection(): String {
|
private fun getFontCssInjection(): String {
|
||||||
return """
|
return """
|
||||||
|
|
@ -314,12 +343,14 @@ class FootnoteJsBridge(
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class LinkNavJsBridge(
|
class LinkNavJsBridge(
|
||||||
private val currentChapterTitle: String
|
private val currentChapterTitle: String,
|
||||||
|
private val onInternalLinkClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onLinkClicked(href: String, epubType: String, linkText: String) {
|
fun onLinkClicked(href: String, epubType: String, linkText: String) {
|
||||||
Timber.tag(TAG_LINK_NAV)
|
Timber.tag(TAG_LINK_NAV)
|
||||||
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
|
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
|
||||||
|
onInternalLinkClick(href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,6 +415,7 @@ fun ChapterWebView(
|
||||||
activeHighlightPalette: List<HighlightColor>,
|
activeHighlightPalette: List<HighlightColor>,
|
||||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||||
onInternalLinkClick: (String) -> Unit,
|
onInternalLinkClick: (String) -> Unit,
|
||||||
|
onWebViewDisposed: (WebView) -> Unit = {},
|
||||||
activeTextureId: String? = null,
|
activeTextureId: String? = null,
|
||||||
activeTextureAlpha: Float = 0.55f
|
activeTextureAlpha: Float = 0.55f
|
||||||
) {
|
) {
|
||||||
|
|
@ -554,7 +586,7 @@ fun ChapterWebView(
|
||||||
}, "AutoScrollBridge"
|
}, "AutoScrollBridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
webChromeClient = object : android.webkit.WebChromeClient() {
|
webChromeClient = object : WebChromeClient() {
|
||||||
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||||
consoleMessage?.let {
|
consoleMessage?.let {
|
||||||
val message = it.message()
|
val message = it.message()
|
||||||
|
|
@ -668,7 +700,9 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
|
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
|
LinkNavJsBridge(chapterTitle) { href ->
|
||||||
|
this.post { onInternalLinkClick(href) }
|
||||||
|
}, "LinkNavBridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
|
|
@ -744,6 +778,37 @@ fun ChapterWebView(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
view?.evaluateJavascript(
|
||||||
|
"""
|
||||||
|
javascript:(function() {
|
||||||
|
if (window.__readerInternalLinkBridgeInstalled) return;
|
||||||
|
window.__readerInternalLinkBridgeInstalled = true;
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
var target = event.target;
|
||||||
|
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
||||||
|
if (!anchor && target && target.parentElement && target.parentElement.closest) {
|
||||||
|
anchor = target.parentElement.closest('a[href]');
|
||||||
|
}
|
||||||
|
if (!anchor) return;
|
||||||
|
var rawHref = anchor.getAttribute('href') || '';
|
||||||
|
if (!rawHref) return;
|
||||||
|
if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return;
|
||||||
|
if (/^\/\//.test(rawHref)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
var resolvedHref = anchor.href || rawHref;
|
||||||
|
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
|
||||||
|
window.LinkNavBridge.onLinkClicked(
|
||||||
|
resolvedHref,
|
||||||
|
anchor.getAttribute('epub:type') || '',
|
||||||
|
anchor.textContent || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
""".trimIndent(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
view?.evaluateJavascript(
|
view?.evaluateJavascript(
|
||||||
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
|
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
|
||||||
escapeJsString(
|
escapeJsString(
|
||||||
|
|
@ -907,10 +972,19 @@ fun ChapterWebView(
|
||||||
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
|
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
|
||||||
}
|
}
|
||||||
webView
|
webView
|
||||||
}, update = { webView ->
|
},
|
||||||
Timber.d(
|
modifier = Modifier.fillMaxSize(),
|
||||||
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
|
onRelease = { releasedWebView ->
|
||||||
)
|
if (localWebViewRef === releasedWebView) {
|
||||||
|
localWebViewRef = null
|
||||||
|
}
|
||||||
|
customMenuState?.finishActionModeCallback?.invoke()
|
||||||
|
customMenuState = null
|
||||||
|
onWebViewDisposed(releasedWebView)
|
||||||
|
releasedWebView.releaseReaderResources()
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
|
||||||
localWebViewRef = webView
|
localWebViewRef = webView
|
||||||
onWebViewInstanceCreated(webView)
|
onWebViewInstanceCreated(webView)
|
||||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||||
|
|
@ -946,7 +1020,7 @@ fun ChapterWebView(
|
||||||
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
|
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}, modifier = Modifier.fillMaxSize()
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
|
@ -88,6 +89,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Remove
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.ScreenRotation
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SwapHoriz
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
|
@ -104,6 +106,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
|
@ -166,8 +169,9 @@ enum class ReaderTool(val title: String, val category: String) {
|
||||||
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
||||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||||
|
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
|
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +262,8 @@ private val epubToolbarTools = setOf(
|
||||||
ReaderTool.FORMAT,
|
ReaderTool.FORMAT,
|
||||||
ReaderTool.SEARCH,
|
ReaderTool.SEARCH,
|
||||||
ReaderTool.AI_FEATURES,
|
ReaderTool.AI_FEATURES,
|
||||||
ReaderTool.TTS_CONTROLS
|
ReaderTool.TTS_CONTROLS,
|
||||||
|
ReaderTool.SCREEN_ORIENTATION
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -272,6 +277,7 @@ fun EpubReaderTopBar(
|
||||||
tapToNavigateEnabled: Boolean,
|
tapToNavigateEnabled: Boolean,
|
||||||
volumeScrollEnabled: Boolean,
|
volumeScrollEnabled: Boolean,
|
||||||
isPageTurnAnimationEnabled: Boolean,
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
|
isRightToLeftPagination: Boolean,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
isKeepScreenOn: Boolean,
|
isKeepScreenOn: Boolean,
|
||||||
onToggleKeepScreenOn: (Boolean) -> Unit,
|
onToggleKeepScreenOn: (Boolean) -> Unit,
|
||||||
|
|
@ -281,12 +287,14 @@ fun EpubReaderTopBar(
|
||||||
onToggleTapToNavigate: (Boolean) -> Unit,
|
onToggleTapToNavigate: (Boolean) -> Unit,
|
||||||
onToggleVolumeScroll: (Boolean) -> Unit,
|
onToggleVolumeScroll: (Boolean) -> Unit,
|
||||||
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
||||||
|
onSetRightToLeftPagination: (Boolean) -> Unit,
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
onOpenTtsSettings: () -> Unit,
|
onOpenTtsSettings: () -> Unit,
|
||||||
onOpenTtsReplacements: () -> Unit,
|
onOpenTtsReplacements: () -> Unit,
|
||||||
onOpenDictionarySettings: () -> Unit,
|
onOpenDictionarySettings: () -> Unit,
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
onOpenVisualOptions: () -> Unit,
|
onOpenVisualOptions: () -> Unit,
|
||||||
|
onOpenScreenOrientation: () -> Unit,
|
||||||
onOpenSlider: () -> Unit,
|
onOpenSlider: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onToggleFormat: () -> Unit,
|
onToggleFormat: () -> Unit,
|
||||||
|
|
@ -414,17 +422,32 @@ fun EpubReaderTopBar(
|
||||||
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.menu_screen_orientation),
|
||||||
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
onClick = onOpenScreenOrientation
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ScreenRotation,
|
||||||
|
contentDescription = stringResource(R.string.menu_screen_orientation),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = stringResource(R.string.tooltip_more_options),
|
text = stringResource(R.string.tooltip_more_options),
|
||||||
description = stringResource(R.string.tooltip_more_options_desc),
|
description = stringResource(R.string.tooltip_more_options_desc),
|
||||||
onClick = {
|
onClick = {
|
||||||
showHiddenToolsExpanded = false
|
showHiddenToolsExpanded = false
|
||||||
|
showReadingModeExpanded = false
|
||||||
|
showTtsSettingsExpanded = false
|
||||||
showMoreMenu = true
|
showMoreMenu = true
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -435,6 +458,8 @@ fun EpubReaderTopBar(
|
||||||
expanded = showMoreMenu,
|
expanded = showMoreMenu,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showHiddenToolsExpanded = false
|
showHiddenToolsExpanded = false
|
||||||
|
showReadingModeExpanded = false
|
||||||
|
showTtsSettingsExpanded = false
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -480,7 +505,8 @@ fun EpubReaderTopBar(
|
||||||
onToggleFormat = onToggleFormat,
|
onToggleFormat = onToggleFormat,
|
||||||
onToggleSearch = onToggleSearch,
|
onToggleSearch = onToggleSearch,
|
||||||
onOpenAiHub = onOpenAiHub,
|
onOpenAiHub = onOpenAiHub,
|
||||||
onToggleTts = onToggleTts
|
onToggleTts = onToggleTts,
|
||||||
|
onOpenScreenOrientation = onOpenScreenOrientation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -528,31 +554,63 @@ fun EpubReaderTopBar(
|
||||||
|
|
||||||
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
|
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||||
enabled = !isTtsActive,
|
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.ArrowDropDown,
|
||||||
contentDescription = stringResource(R.string.content_desc_selected)
|
contentDescription = null,
|
||||||
|
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
DropdownMenuItem(
|
)
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
if (showReadingModeExpanded) {
|
||||||
enabled = !isTtsActive,
|
DropdownMenuItem(
|
||||||
onClick = {
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||||
showMoreMenu = false
|
enabled = !isTtsActive,
|
||||||
onChangeRenderMode(RenderMode.PAGINATED)
|
onClick = {
|
||||||
},
|
showMoreMenu = false
|
||||||
trailingIcon = {
|
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||||
if (currentRenderMode == RenderMode.PAGINATED) Icon(
|
},
|
||||||
Icons.Default.Check,
|
trailingIcon = {
|
||||||
contentDescription = stringResource(R.string.content_desc_selected)
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
||||||
)
|
Icons.Default.Check,
|
||||||
})
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||||
|
enabled = !isTtsActive,
|
||||||
|
onClick = {
|
||||||
|
onSetRightToLeftPagination(false)
|
||||||
|
showMoreMenu = false
|
||||||
|
onChangeRenderMode(RenderMode.PAGINATED)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||||
|
enabled = !isTtsActive,
|
||||||
|
onClick = {
|
||||||
|
onSetRightToLeftPagination(true)
|
||||||
|
showMoreMenu = false
|
||||||
|
onChangeRenderMode(RenderMode.PAGINATED)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
|
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
|
||||||
|
|
@ -664,39 +722,62 @@ fun EpubReaderTopBar(
|
||||||
})
|
})
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
|
val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
|
||||||
|
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
||||||
|
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||||
enabled = !isTtsActive,
|
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onOpenTtsSettings()
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.GraphicEq,
|
Icons.Default.GraphicEq,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onOpenTtsReplacements()
|
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.GraphicEq,
|
Icons.Default.ArrowDropDown,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (showTtsSettingsExpanded) {
|
||||||
|
if (showTtsVoiceSettings) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||||
|
enabled = !isTtsActive,
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenTtsSettings()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.GraphicEq,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTtsReplacements) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenTtsReplacements()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.GraphicEq,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -706,6 +787,89 @@ fun EpubReaderTopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EpubJumpHistoryBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showStandardBars: Boolean,
|
||||||
|
searchStateActive: Boolean,
|
||||||
|
backLabel: String?,
|
||||||
|
forwardLabel: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onForward: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null),
|
||||||
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||||
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 3.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onBack,
|
||||||
|
enabled = backLabel != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_jump_back),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = backLabel.orEmpty(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onClear,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_clear),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.action_clear), maxLines = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onForward,
|
||||||
|
enabled = forwardLabel != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = forwardLabel.orEmpty(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_jump_forward),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@androidx.annotation.OptIn(UnstableApi::class)
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EpubReaderBottomBar(
|
fun EpubReaderBottomBar(
|
||||||
|
|
@ -723,6 +887,7 @@ fun EpubReaderBottomBar(
|
||||||
onOpenDictionarySettings: () -> Unit,
|
onOpenDictionarySettings: () -> Unit,
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
onToggleTts: () -> Unit,
|
onToggleTts: () -> Unit,
|
||||||
|
onOpenScreenOrientation: () -> Unit,
|
||||||
hiddenTools: Set<String>,
|
hiddenTools: Set<String>,
|
||||||
toolOrder: List<ReaderTool>,
|
toolOrder: List<ReaderTool>,
|
||||||
bottomTools: Set<String>,
|
bottomTools: Set<String>,
|
||||||
|
|
@ -835,6 +1000,16 @@ fun EpubReaderBottomBar(
|
||||||
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.menu_screen_orientation),
|
||||||
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
onClick = onOpenScreenOrientation
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ScreenRotation,
|
||||||
|
contentDescription = stringResource(R.string.menu_screen_orientation)
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
|
||||||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem(
|
||||||
onToggleFormat: () -> Unit,
|
onToggleFormat: () -> Unit,
|
||||||
onToggleSearch: () -> Unit,
|
onToggleSearch: () -> Unit,
|
||||||
onOpenAiHub: () -> Unit,
|
onOpenAiHub: () -> Unit,
|
||||||
onToggleTts: () -> Unit
|
onToggleTts: () -> Unit,
|
||||||
|
onOpenScreenOrientation: () -> Unit
|
||||||
) {
|
) {
|
||||||
val enabled = when (tool) {
|
val enabled = when (tool) {
|
||||||
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
|
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
|
||||||
|
|
@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem(
|
||||||
ReaderTool.SEARCH -> onToggleSearch()
|
ReaderTool.SEARCH -> onToggleSearch()
|
||||||
ReaderTool.AI_FEATURES -> onOpenAiHub()
|
ReaderTool.AI_FEATURES -> onOpenAiHub()
|
||||||
ReaderTool.TTS_CONTROLS -> onToggleTts()
|
ReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||||
|
ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,12 +26,13 @@ import android.view.GestureDetector
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.ActionMode
|
import android.view.ActionMode
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuInflater
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.PopupMenu
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
||||||
|
|
@ -59,7 +60,143 @@ class InteractiveWebView(
|
||||||
|
|
||||||
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
||||||
private var scrollStopRunnable: Runnable? = null
|
private var scrollStopRunnable: Runnable? = null
|
||||||
private var mCustomCallback: ActionMode.Callback? = null
|
private var activeSelectionActionMode: ActionMode? = null
|
||||||
|
|
||||||
|
private fun clearPendingSelectionWork() {
|
||||||
|
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
|
scrollStopRunnable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocalSelectionActionMode(): ActionMode {
|
||||||
|
activeSelectionActionMode?.let { existingMode ->
|
||||||
|
showCustomSelectionMenuFromCurrentSelection(existingMode)
|
||||||
|
return existingMode
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var localMode: ActionMode
|
||||||
|
localMode = LocalSelectionActionMode(this) {
|
||||||
|
if (activeSelectionActionMode === localMode) {
|
||||||
|
activeSelectionActionMode = null
|
||||||
|
}
|
||||||
|
onHideCustomSelectionMenu()
|
||||||
|
}
|
||||||
|
activeSelectionActionMode = localMode
|
||||||
|
showCustomSelectionMenuFromCurrentSelection(localMode)
|
||||||
|
return localMode
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishLocalSelectionActionMode() {
|
||||||
|
activeSelectionActionMode?.finish()
|
||||||
|
activeSelectionActionMode = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
|
||||||
|
val jsToGetSelectionDetails = """
|
||||||
|
(function() {
|
||||||
|
var selection = window.getSelection();
|
||||||
|
var selectedText = selection.toString().trim();
|
||||||
|
if (selectedText.length === 0 || selection.rangeCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var range = selection.getRangeAt(0);
|
||||||
|
var rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// If getBoundingClientRect returns all zeros, try getClientRects()
|
||||||
|
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
||||||
|
var clientRects = range.getClientRects();
|
||||||
|
if (clientRects.length > 0) {
|
||||||
|
rect = clientRects[0]; // Use the first rect
|
||||||
|
} else {
|
||||||
|
return null; // No valid rect found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the rect has some dimension
|
||||||
|
if (rect.width === 0 && rect.height === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
text: selectedText,
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
||||||
|
if (activeSelectionActionMode !== mode) {
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
||||||
|
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
||||||
|
mode.finish()
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
|
||||||
|
val selectionDetails = JSONObject(unquotedJsonResult)
|
||||||
|
val selectedText = selectionDetails.getString("text")
|
||||||
|
|
||||||
|
if (selectedText.isBlank()) {
|
||||||
|
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
||||||
|
mode.finish()
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsLeft = selectionDetails.getDouble("left")
|
||||||
|
val jsTop = selectionDetails.getDouble("top")
|
||||||
|
val jsRight = selectionDetails.getDouble("right")
|
||||||
|
val jsBottom = selectionDetails.getDouble("bottom")
|
||||||
|
val jsWidth = selectionDetails.getDouble("width")
|
||||||
|
val jsHeight = selectionDetails.getDouble("height")
|
||||||
|
|
||||||
|
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
||||||
|
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
||||||
|
mode.finish()
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = context.resources.displayMetrics.density
|
||||||
|
|
||||||
|
val webViewLocation = IntArray(2)
|
||||||
|
getLocationOnScreen(webViewLocation)
|
||||||
|
val webViewX = webViewLocation[0]
|
||||||
|
val webViewY = webViewLocation[1]
|
||||||
|
|
||||||
|
val selectionRectScreen = Rect(
|
||||||
|
(webViewX + jsLeft * density).toInt(),
|
||||||
|
(webViewY + jsTop * density).toInt(),
|
||||||
|
(webViewX + jsRight * density).toInt(),
|
||||||
|
(webViewY + jsBottom * density).toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||||
|
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||||
|
mode.finish()
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
||||||
|
|
||||||
|
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||||
|
mode.finish()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
||||||
|
mode.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val gestureDetector =
|
private val gestureDetector =
|
||||||
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
|
|
@ -167,148 +304,12 @@ class InteractiveWebView(
|
||||||
return super.onTouchEvent(event)
|
return super.onTouchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
|
||||||
|
// selections use the app's Compose popup without starting the platform toolbar.
|
||||||
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
||||||
if (type == ActionMode.TYPE_FLOATING) {
|
if (type == ActionMode.TYPE_FLOATING) {
|
||||||
if (mCustomCallback == null) {
|
Timber.d("CustomSelection: handling floating action mode locally.")
|
||||||
mCustomCallback = object : ActionMode.Callback2() {
|
return startLocalSelectionActionMode()
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
Timber.d("CustomSelection: onCreateActionMode")
|
|
||||||
menu.clear()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
Timber.d("CustomSelection: onPrepareActionMode")
|
|
||||||
menu.clear()
|
|
||||||
|
|
||||||
val jsToGetSelectionDetails = """
|
|
||||||
(function() {
|
|
||||||
var selection = window.getSelection();
|
|
||||||
var selectedText = selection.toString().trim();
|
|
||||||
if (selectedText.length === 0 || selection.rangeCount === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var range = selection.getRangeAt(0);
|
|
||||||
var rect = range.getBoundingClientRect();
|
|
||||||
|
|
||||||
// If getBoundingClientRect returns all zeros, try getClientRects()
|
|
||||||
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
|
||||||
var clientRects = range.getClientRects();
|
|
||||||
if (clientRects.length > 0) {
|
|
||||||
rect = clientRects[0]; // Use the first rect
|
|
||||||
} else {
|
|
||||||
return null; // No valid rect found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the rect has some dimension
|
|
||||||
if (rect.width === 0 && rect.height === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
text: selectedText,
|
|
||||||
left: rect.left,
|
|
||||||
top: rect.top,
|
|
||||||
right: rect.right,
|
|
||||||
bottom: rect.bottom,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
|
||||||
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
|
||||||
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
|
|
||||||
.replace("\\\"", "\"")
|
|
||||||
.replace("\\\\", "\\")
|
|
||||||
|
|
||||||
val selectionDetails = JSONObject(unquotedJsonResult)
|
|
||||||
val selectedText = selectionDetails.getString("text")
|
|
||||||
|
|
||||||
if (selectedText.isBlank()) {
|
|
||||||
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
|
||||||
}
|
|
||||||
|
|
||||||
val jsLeft = selectionDetails.getDouble("left")
|
|
||||||
val jsTop = selectionDetails.getDouble("top")
|
|
||||||
val jsRight = selectionDetails.getDouble("right")
|
|
||||||
val jsBottom = selectionDetails.getDouble("bottom")
|
|
||||||
val jsWidth = selectionDetails.getDouble("width")
|
|
||||||
val jsHeight = selectionDetails.getDouble("height")
|
|
||||||
|
|
||||||
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
|
||||||
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = context.resources.displayMetrics.density
|
|
||||||
|
|
||||||
val webViewLocation = IntArray(2)
|
|
||||||
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
|
|
||||||
val webViewX = webViewLocation[0]
|
|
||||||
val webViewY = webViewLocation[1]
|
|
||||||
|
|
||||||
val selectionRectScreen = Rect(
|
|
||||||
(webViewX + jsLeft * density).toInt(),
|
|
||||||
(webViewY + jsTop * density).toInt(),
|
|
||||||
(webViewX + jsRight * density).toInt(),
|
|
||||||
(webViewY + jsBottom * density).toInt()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
|
||||||
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
|
||||||
|
|
||||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
mode.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
|
|
||||||
onHideCustomSelectionMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
|
|
||||||
super.onGetContentRect(mode, view, outRect)
|
|
||||||
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.startActionMode(mCustomCallback, type)
|
|
||||||
}
|
}
|
||||||
return super.startActionMode(originalCallback, type)
|
return super.startActionMode(originalCallback, type)
|
||||||
}
|
}
|
||||||
|
|
@ -316,18 +317,79 @@ class InteractiveWebView(
|
||||||
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||||
super.onScrollChanged(l, t, oldl, oldt)
|
super.onScrollChanged(l, t, oldl, oldt)
|
||||||
|
|
||||||
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
clearPendingSelectionWork()
|
||||||
scrollStopRunnable = Runnable {
|
scrollStopRunnable = Runnable {
|
||||||
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
|
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
|
||||||
val selectedText = result?.removeSurrounding("\"")
|
val selectedText = result?.removeSurrounding("\"")
|
||||||
if (!selectedText.isNullOrBlank()) {
|
if (!selectedText.isNullOrBlank()) {
|
||||||
Timber.d("Selection exists after scroll. Restarting action mode.")
|
Timber.d("Selection exists after scroll. Restarting action mode.")
|
||||||
mCustomCallback?.let {
|
startLocalSelectionActionMode()
|
||||||
startActionMode(it, ActionMode.TYPE_FLOATING)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
|
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
clearPendingSelectionWork()
|
||||||
|
finishLocalSelectionActionMode()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun destroy() {
|
||||||
|
clearPendingSelectionWork()
|
||||||
|
finishLocalSelectionActionMode()
|
||||||
|
super.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LocalSelectionActionMode(
|
||||||
|
anchorView: View,
|
||||||
|
private val onFinished: () -> Unit
|
||||||
|
) : ActionMode() {
|
||||||
|
private val modeContext = anchorView.context
|
||||||
|
private val menu: Menu = PopupMenu(modeContext, anchorView).menu
|
||||||
|
private val menuInflater = MenuInflater(modeContext)
|
||||||
|
private var title: CharSequence? = null
|
||||||
|
private var subtitle: CharSequence? = null
|
||||||
|
private var customView: View? = null
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
override fun setTitle(title: CharSequence?) {
|
||||||
|
this.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTitle(resId: Int) {
|
||||||
|
title = modeContext.getText(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSubtitle(subtitle: CharSequence?) {
|
||||||
|
this.subtitle = subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSubtitle(resId: Int) {
|
||||||
|
subtitle = modeContext.getText(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCustomView(view: View?) {
|
||||||
|
customView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun invalidate() = Unit
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
onFinished()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMenu(): Menu = menu
|
||||||
|
|
||||||
|
override fun getTitle(): CharSequence? = title
|
||||||
|
|
||||||
|
override fun getSubtitle(): CharSequence? = subtitle
|
||||||
|
|
||||||
|
override fun getCustomView(): View? = customView
|
||||||
|
|
||||||
|
override fun getMenuInflater(): MenuInflater = menuInflater
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
|
@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
|
||||||
private const val PRIORITY_HIGH = 1
|
private const val PRIORITY_HIGH = 1
|
||||||
private const val PRIORITY_MEDIUM = 2
|
private const val PRIORITY_MEDIUM = 2
|
||||||
private const val PRIORITY_LOW = 3
|
private const val PRIORITY_LOW = 3
|
||||||
|
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||||
|
|
||||||
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
|
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
|
||||||
|
|
||||||
|
|
@ -187,6 +190,7 @@ class BookPaginator(
|
||||||
|
|
||||||
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
||||||
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
||||||
|
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
|
||||||
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
||||||
private var paginationWorker: Job? = null
|
private var paginationWorker: Job? = null
|
||||||
|
|
||||||
|
|
@ -430,9 +434,9 @@ class BookPaginator(
|
||||||
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
|
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
pageCache.put(chapterIndex, pages)
|
|
||||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||||
|
pageCache.put(chapterIndex, pages)
|
||||||
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
||||||
pages
|
pages
|
||||||
}
|
}
|
||||||
|
|
@ -574,8 +578,86 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
|
||||||
|
if (chapterIndex !in chapters.indices) {
|
||||||
|
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
pageCache[chapterIndex]?.let {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||||
|
.d("ensure_chapter hit_memory chapter=$chapterIndex pages=${it.size}")
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
val lock = chapterPaginationLocks.computeIfAbsent(chapterIndex) { Mutex() }
|
||||||
|
return lock.withLock {
|
||||||
|
pageCache[chapterIndex]?.also {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||||
|
.d("ensure_chapter hit_after_wait chapter=$chapterIndex pages=${it.size}")
|
||||||
|
} ?: run {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||||
|
.d("ensure_chapter paginate chapter=$chapterIndex finalized=${chapterIndex in finalizedChapterCounts}")
|
||||||
|
paginateChapter(chapterIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
|
||||||
|
)
|
||||||
|
return resolveStableChapterStartPage(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
chapterCount = chapters.size,
|
||||||
|
pageCountsAreAccurate = pageCountsAreAccurate,
|
||||||
|
chapterStartPage = { chapterStartPageIndices[it] },
|
||||||
|
isChapterFinalized = { it in finalizedChapterCounts },
|
||||||
|
ensureChapterPaginated = { ensureChapterPaginated(it) != null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findStableChapterStartPage(chapterIndex: Int): Int? = withContext(Dispatchers.IO) {
|
||||||
|
val stableStart = ensureStableStartPageForChapter(chapterIndex) ?: return@withContext null
|
||||||
|
val targetPages = ensureChapterPaginated(chapterIndex) ?: return@withContext null
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"stable_chapter_start resolved chapter=$chapterIndex page=$stableStart targetPages=${targetPages.size}"
|
||||||
|
)
|
||||||
|
stableStart.takeIf { targetPages.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findStablePageForLocator(locator: Locator): Int? = withContext(Dispatchers.IO) {
|
||||||
|
val targetChapterIndex = locator.chapterIndex
|
||||||
|
Timber.tag("POS_DIAG").d("findStablePageForLocator: Searching for $locator")
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d("stable_locator request locator=$locator")
|
||||||
|
|
||||||
|
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||||
|
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||||
|
|
||||||
|
Timber.tag("POS_DIAG").d(
|
||||||
|
"findStablePageForLocator: targetChapterIndex=$targetChapterIndex, stableStart=$chapterStartPage, chapterPages.size=${chapterPages?.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (chapterPages.isNullOrEmpty() || chapterStartPage == null) {
|
||||||
|
Timber.e("Stable locator navigation failed: Could not stabilize target chapter $targetChapterIndex.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
|
||||||
|
Timber.tag("POS_DIAG").e("findStablePageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalPageIndex = chapterStartPage + pageInChapter
|
||||||
|
Timber.tag("POS_DIAG").i("findStablePageForLocator: FOUND absolute page $finalPageIndex")
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"stable_locator resolved locator=$locator page=$finalPageIndex chapterStart=$chapterStartPage pageInChapter=$pageInChapter"
|
||||||
|
)
|
||||||
|
finalPageIndex
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
|
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
|
||||||
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
val pages = ensureChapterPaginated(chapterIndex)
|
||||||
if (pages.isNullOrEmpty()) {
|
if (pages.isNullOrEmpty()) {
|
||||||
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
|
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
|
||||||
return null
|
return null
|
||||||
|
|
@ -794,7 +876,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
|
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
|
||||||
val pages = paginateChapter(chapterIndex)
|
val pages = ensureChapterPaginated(chapterIndex)
|
||||||
|
|
||||||
if (pages != null) {
|
if (pages != null) {
|
||||||
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
|
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
|
||||||
|
|
@ -836,6 +918,9 @@ class BookPaginator(
|
||||||
val difference = actualPageCount - estimatedPageCount
|
val difference = actualPageCount - estimatedPageCount
|
||||||
|
|
||||||
if (difference == 0) {
|
if (difference == 0) {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
|
||||||
|
)
|
||||||
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
||||||
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
||||||
}
|
}
|
||||||
|
|
@ -854,8 +939,16 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
rebuildChapterStartSnapshot()
|
rebuildChapterStartSnapshot()
|
||||||
|
|
||||||
if (chapterIndex < currentUserChapterIndex.value) {
|
val currentUserChapter = currentUserChapterIndex.value
|
||||||
pageShiftRequest.tryEmit(difference)
|
val shouldShiftCurrentPage = chapterIndex < currentUserChapter
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount"
|
||||||
|
)
|
||||||
|
if (shouldShiftCurrentPage) {
|
||||||
|
val emitted = pageShiftRequest.tryEmit(difference)
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1028,13 +1121,12 @@ class BookPaginator(
|
||||||
)
|
)
|
||||||
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
||||||
|
|
||||||
pageCache.put(chapterIndex, pages)
|
|
||||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
|
||||||
|
|
||||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||||
savePageCacheAsync(chapter, chapterIndex, pages)
|
savePageCacheAsync(chapter, chapterIndex, pages)
|
||||||
|
|
||||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||||
|
pageCache.put(chapterIndex, pages)
|
||||||
|
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1105,12 +1197,68 @@ class BookPaginator(
|
||||||
return Jsoup.parse(htmlToParse).body().text()
|
return Jsoup.parse(htmlToParse).body().text()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
|
suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
|
||||||
if (targetChapterIndex <= 0) {
|
Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
|
||||||
return 0
|
|
||||||
|
if (anchor.isNullOrBlank()) {
|
||||||
|
return@withContext findStableChapterStartPage(chapterIndex)
|
||||||
}
|
}
|
||||||
val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
|
|
||||||
return startIndex
|
// 1. QUICK LOOKUP: Check the Anchor Index first
|
||||||
|
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||||
|
|
||||||
|
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
||||||
|
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
||||||
|
indexEntry.chapterIndex to indexEntry.blockIndex
|
||||||
|
} else {
|
||||||
|
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
||||||
|
chapterIndex to null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
||||||
|
val chapterPages = ensureChapterPaginated(targetChapter)
|
||||||
|
val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
|
||||||
|
|
||||||
|
if (chapterPages == null || chapterStartPage == null) {
|
||||||
|
Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
||||||
|
chapterPageNavigationIndex[targetChapter]
|
||||||
|
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||||
|
?.pageInChapter
|
||||||
|
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
||||||
|
|
||||||
|
if (indexedPageInChapter != null) {
|
||||||
|
val finalPage = chapterStartPage + indexedPageInChapter
|
||||||
|
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
||||||
|
return@withContext finalPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. FIND PAGE
|
||||||
|
var targetPageInChapter = 0
|
||||||
|
var found = false
|
||||||
|
|
||||||
|
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||||
|
val isMatch = if (targetBlock != null) {
|
||||||
|
// Fast path: We know exactly which block we are looking for
|
||||||
|
page.content.any { it.blockIndex == targetBlock }
|
||||||
|
} else {
|
||||||
|
// Slow path: Linear ID scan (fallback)
|
||||||
|
page.content.any { containsAnchor(it, anchor) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
targetPageInChapter = pageIndex
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalPage = chapterStartPage + targetPageInChapter
|
||||||
|
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
||||||
|
finalPage
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPageForAnchor(
|
override fun findPageForAnchor(
|
||||||
|
|
@ -1119,70 +1267,8 @@ class BookPaginator(
|
||||||
onResult: (pageIndex: Int) -> Unit
|
onResult: (pageIndex: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
|
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
|
||||||
|
withContext(Dispatchers.Main) { onResult(page) }
|
||||||
if (anchor.isNullOrBlank()) {
|
|
||||||
val start = chapterStartPageIndices[chapterIndex] ?: 0
|
|
||||||
withContext(Dispatchers.Main) { onResult(start) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. QUICK LOOKUP: Check the Anchor Index first
|
|
||||||
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
|
||||||
|
|
||||||
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
|
||||||
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
|
||||||
indexEntry.chapterIndex to indexEntry.blockIndex
|
|
||||||
} else {
|
|
||||||
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
|
||||||
chapterIndex to null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
|
||||||
val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter)
|
|
||||||
val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0
|
|
||||||
|
|
||||||
if (chapterPages == null) {
|
|
||||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
|
||||||
chapterPageNavigationIndex[targetChapter]
|
|
||||||
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
|
||||||
?.pageInChapter
|
|
||||||
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
|
||||||
|
|
||||||
if (indexedPageInChapter != null) {
|
|
||||||
val finalPage = chapterStartPage + indexedPageInChapter
|
|
||||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
|
||||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. FIND PAGE
|
|
||||||
var targetPageInChapter = 0
|
|
||||||
var found = false
|
|
||||||
|
|
||||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
|
||||||
val isMatch = if (targetBlock != null) {
|
|
||||||
// Fast path: We know exactly which block we are looking for
|
|
||||||
page.content.any { it.blockIndex == targetBlock }
|
|
||||||
} else {
|
|
||||||
// Slow path: Linear ID scan (fallback)
|
|
||||||
page.content.any { containsAnchor(it, anchor) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMatch) {
|
|
||||||
targetPageInChapter = pageIndex
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val finalPage = chapterStartPage + targetPageInChapter
|
|
||||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
|
||||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1234,69 +1320,79 @@ class BookPaginator(
|
||||||
onNavigationComplete: (pageIndex: Int) -> Unit
|
onNavigationComplete: (pageIndex: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
|
||||||
|
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
|
||||||
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
|
||||||
if (targetChapterPath == null) {
|
|
||||||
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
|
||||||
if (targetChapterIndex == -1) {
|
|
||||||
Timber.w("Could not find chapter for path: $targetChapterPath")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) {
|
||||||
|
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
||||||
|
|
||||||
|
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||||
|
if (targetChapterPath == null) {
|
||||||
|
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||||
|
if (targetChapterIndex == -1) {
|
||||||
|
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
findStablePageForAnchor(targetChapterIndex, anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
|
||||||
|
val targetChapterIndex = result.locationInSource
|
||||||
|
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||||
|
|
||||||
|
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||||
|
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||||
|
|
||||||
|
if (chapterPages == null || chapterStartPage == null) {
|
||||||
|
Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPageInChapter = 0
|
||||||
|
var occurrenceCount = 0
|
||||||
|
|
||||||
|
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||||
|
for (block in page.content) {
|
||||||
|
val textToSearch = when (block) {
|
||||||
|
is ParagraphBlock -> block.content.text
|
||||||
|
is HeaderBlock -> block.content.text
|
||||||
|
is QuoteBlock -> block.content.text
|
||||||
|
is ListItemBlock -> block.content.text
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToSearch != null) {
|
||||||
|
var lastIndex = -1
|
||||||
|
while (true) {
|
||||||
|
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||||
|
if (lastIndex == -1) break
|
||||||
|
|
||||||
|
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||||
|
targetPageInChapter = pageIndex
|
||||||
|
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
||||||
|
break@pageLoop
|
||||||
|
}
|
||||||
|
occurrenceCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||||
|
Timber.i("Search result found. Final page index: $finalPageIndex")
|
||||||
|
finalPageIndex
|
||||||
|
}
|
||||||
|
|
||||||
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
val targetChapterIndex = result.locationInSource
|
val page = findStablePageForSearchResult(result) ?: return@launch
|
||||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
withContext(Dispatchers.Main) { onResult(page) }
|
||||||
|
|
||||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
|
||||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
|
||||||
|
|
||||||
if (chapterPages == null) {
|
|
||||||
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetPageInChapter = 0
|
|
||||||
var occurrenceCount = 0
|
|
||||||
|
|
||||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
|
||||||
for (block in page.content) {
|
|
||||||
val textToSearch = when (block) {
|
|
||||||
is ParagraphBlock -> block.content.text
|
|
||||||
is HeaderBlock -> block.content.text
|
|
||||||
is QuoteBlock -> block.content.text
|
|
||||||
is ListItemBlock -> block.content.text
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textToSearch != null) {
|
|
||||||
var lastIndex = -1
|
|
||||||
while (true) {
|
|
||||||
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
|
||||||
if (lastIndex == -1) break
|
|
||||||
|
|
||||||
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
|
||||||
targetPageInChapter = pageIndex
|
|
||||||
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
|
||||||
break@pageLoop
|
|
||||||
}
|
|
||||||
occurrenceCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
|
||||||
Timber.i("Search result found. Final page index: $finalPageIndex")
|
|
||||||
withContext(Dispatchers.Main) { onResult(finalPageIndex) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1331,20 +1427,8 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findPageForLocator(locator: Locator): Int? {
|
private fun findPageInChapterForLocator(locator: Locator, chapterPages: List<Page>): Int? {
|
||||||
val targetChapterIndex = locator.chapterIndex
|
val targetChapterIndex = locator.chapterIndex
|
||||||
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
|
|
||||||
|
|
||||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
|
||||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
|
||||||
|
|
||||||
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
|
|
||||||
|
|
||||||
if (chapterPages.isNullOrEmpty()) {
|
|
||||||
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
chapterTextRangeIndex[targetChapterIndex]
|
chapterTextRangeIndex[targetChapterIndex]
|
||||||
?.firstOrNull { range ->
|
?.firstOrNull { range ->
|
||||||
range.blockIndex == locator.blockIndex &&
|
range.blockIndex == locator.blockIndex &&
|
||||||
|
|
@ -1352,17 +1436,15 @@ class BookPaginator(
|
||||||
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
|
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
|
||||||
}
|
}
|
||||||
?.let { range ->
|
?.let { range ->
|
||||||
val finalPageIndex = chapterStartPage + range.pageInChapter
|
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
|
||||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
|
return range.pageInChapter
|
||||||
return finalPageIndex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chapterPageNavigationIndex[targetChapterIndex]
|
chapterPageNavigationIndex[targetChapterIndex]
|
||||||
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||||
?.let { entry ->
|
?.let { entry ->
|
||||||
val finalPageIndex = chapterStartPage + entry.pageInChapter
|
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
|
||||||
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
|
return entry.pageInChapter
|
||||||
return finalPageIndex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallbackPageInChapter = -1
|
var fallbackPageInChapter = -1
|
||||||
|
|
@ -1370,7 +1452,7 @@ class BookPaginator(
|
||||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||||
val allTextBlocks = getAllTextBlocks(page.content)
|
val allTextBlocks = getAllTextBlocks(page.content)
|
||||||
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
|
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
|
||||||
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
|
Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex")
|
||||||
}
|
}
|
||||||
for (textBlock in allTextBlocks) {
|
for (textBlock in allTextBlocks) {
|
||||||
if (textBlock.blockIndex == locator.blockIndex) {
|
if (textBlock.blockIndex == locator.blockIndex) {
|
||||||
|
|
@ -1384,15 +1466,13 @@ class BookPaginator(
|
||||||
|
|
||||||
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
|
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
|
||||||
if (isInside) {
|
if (isInside) {
|
||||||
val finalPageIndex = chapterStartPage + pageIndex
|
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND match on pageInChapter $pageIndex")
|
||||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
|
return pageIndex
|
||||||
return finalPageIndex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
|
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
|
||||||
val finalPageIndex = chapterStartPage + pageIndex
|
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND empty block match on pageInChapter $pageIndex")
|
||||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
|
return pageIndex
|
||||||
return finalPageIndex
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1408,15 +1488,37 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackPageInChapter != -1) {
|
if (fallbackPageInChapter != -1) {
|
||||||
val finalPageIndex = chapterStartPage + fallbackPageInChapter
|
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Exact offset not found, using block-start fallback pageInChapter $fallbackPageInChapter")
|
||||||
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
|
return fallbackPageInChapter
|
||||||
return finalPageIndex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findPageForLocator(locator: Locator): Int? {
|
||||||
|
val targetChapterIndex = locator.chapterIndex
|
||||||
|
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
|
||||||
|
|
||||||
|
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||||
|
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||||
|
|
||||||
|
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
|
||||||
|
|
||||||
|
if (chapterPages.isNullOrEmpty()) {
|
||||||
|
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
|
||||||
|
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalPageIndex = chapterStartPage + pageInChapter
|
||||||
|
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND absolute page $finalPageIndex")
|
||||||
|
return finalPageIndex
|
||||||
|
}
|
||||||
|
|
||||||
fun getLocatorForPage(pageIndex: Int): Locator? {
|
fun getLocatorForPage(pageIndex: Int): Locator? {
|
||||||
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
|
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
|
||||||
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
||||||
|
|
@ -1472,13 +1574,12 @@ class BookPaginator(
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
||||||
|
|
||||||
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
val chapterPages = ensureChapterPaginated(chapterIndex)
|
||||||
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
|
val chapterStartPage = ensureStableStartPageForChapter(chapterIndex)
|
||||||
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
|
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
|
||||||
|
|
||||||
if (chapterPages == null) {
|
if (chapterPages == null || chapterStartPage == null) {
|
||||||
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")
|
Timber.e("CFI Navigation failed: Could not stabilize target chapter $chapterIndex.")
|
||||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")
|
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,14 @@ class ContentStyler(
|
||||||
baselineShift = baselineShift
|
baselineShift = baselineShift
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!span.linkHref.isNullOrBlank()) {
|
||||||
|
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
|
themeTextColor = themeTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
|
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
|
||||||
themedSpanStyle.textDecorationColor.isSpecified ||
|
themedSpanStyle.textDecorationColor.isSpecified ||
|
||||||
themedSpanStyle.textUnderlineOffset.isSpecified
|
themedSpanStyle.textUnderlineOffset.isSpecified
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import com.aryan.reader.BuildConfig
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
@ -44,6 +43,8 @@ class MathMLRenderer(private val context: Context) {
|
||||||
|
|
||||||
private var webView: WebView? = null
|
private var webView: WebView? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
@Volatile
|
||||||
|
private var isDestroyed = false
|
||||||
private var isMathJaxReady = false
|
private var isMathJaxReady = false
|
||||||
private val readySignal = CompletableDeferred<Boolean>()
|
private val readySignal = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
|
@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
handler.post {
|
handler.post {
|
||||||
setupWebView()
|
if (!isDestroyed) {
|
||||||
|
setupWebView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun awaitReady(): Boolean {
|
suspend fun awaitReady(): Boolean {
|
||||||
|
if (isDestroyed) return false
|
||||||
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
|
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
|
||||||
return withTimeoutOrNull(10_000) {
|
return withTimeoutOrNull(10_000) {
|
||||||
readySignal.await()
|
readySignal.await()
|
||||||
|
|
@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
|
||||||
|
|
||||||
private fun setupWebView() {
|
private fun setupWebView() {
|
||||||
try {
|
try {
|
||||||
if (BuildConfig.DEBUG) {
|
if (isDestroyed) return
|
||||||
WebView.setWebContentsDebuggingEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
webView = WebView(context).apply {
|
webView = WebView(context).apply {
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
|
@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun render(mathML: String, originalAltText: String): RenderResult {
|
suspend fun render(mathML: String, originalAltText: String): RenderResult {
|
||||||
|
if (isDestroyed) {
|
||||||
|
return RenderResult.Failure(originalAltText)
|
||||||
|
}
|
||||||
if (!awaitReady()) {
|
if (!awaitReady()) {
|
||||||
Timber.e("WebView is not available or failed to initialize. Failing render.")
|
Timber.e("WebView is not available or failed to initialize. Failing render.")
|
||||||
return RenderResult.Failure(originalAltText)
|
return RenderResult.Failure(originalAltText)
|
||||||
|
|
@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processNextJob() {
|
private fun processNextJob() {
|
||||||
|
if (isDestroyed) {
|
||||||
|
isProcessing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
synchronized(jobQueue) {
|
synchronized(jobQueue) {
|
||||||
if (jobQueue.isEmpty()) {
|
if (jobQueue.isEmpty()) {
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
|
@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeRender() {
|
private fun executeRender() {
|
||||||
|
if (isDestroyed) {
|
||||||
|
isProcessing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isMathJaxReady) {
|
if (!isMathJaxReady) {
|
||||||
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
|
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
|
||||||
handler.postDelayed({ executeRender() }, 100)
|
handler.postDelayed({ executeRender() }, 100)
|
||||||
|
|
@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
isDestroyed = true
|
||||||
|
if (!readySignal.isCompleted) {
|
||||||
|
readySignal.complete(false)
|
||||||
|
}
|
||||||
|
val pendingJobs = synchronized(jobQueue) {
|
||||||
|
val copy = jobQueue.toList()
|
||||||
|
jobQueue.clear()
|
||||||
|
isProcessing = false
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
pendingJobs.forEach { job ->
|
||||||
|
job.continuation(RenderResult.Failure(extractAltText(job.mathML)))
|
||||||
|
}
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
handler.post {
|
handler.post {
|
||||||
webView?.destroy()
|
webView?.releaseMathRendererResources()
|
||||||
webView = null
|
webView = null
|
||||||
Timber.d("MathMLRenderer WebView destroyed.")
|
Timber.d("MathMLRenderer WebView destroyed.")
|
||||||
}
|
}
|
||||||
synchronized(jobQueue) {
|
}
|
||||||
jobQueue.clear()
|
|
||||||
isProcessing = false
|
private fun extractAltText(mathML: String): String =
|
||||||
|
mathML.substringAfter("alttext=\"", "").substringBefore("\"")
|
||||||
|
.ifBlank { "MathML rendering failed" }
|
||||||
|
|
||||||
|
private fun WebView.releaseMathRendererResources() {
|
||||||
|
try {
|
||||||
|
stopLoading()
|
||||||
|
removeJavascriptInterface("AndroidBridge")
|
||||||
|
webChromeClient = null
|
||||||
|
webViewClient = WebViewClient()
|
||||||
|
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
|
||||||
|
clearHistory()
|
||||||
|
removeAllViews()
|
||||||
|
destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to fully release MathML WebView resources")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) {
|
||||||
} else {
|
} else {
|
||||||
Timber.e("onSvgReady FAILURE. Received empty SVG.")
|
Timber.e("onSvgReady FAILURE. Received empty SVG.")
|
||||||
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
|
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
|
||||||
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
|
val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed"
|
||||||
completeCurrentJob(RenderResult.Failure(altText))
|
completeCurrentJob(RenderResult.Failure(altText))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||||
|
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||||
|
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
||||||
|
|
||||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||||
|
|
@ -410,6 +413,30 @@ internal object CfiUtils {
|
||||||
|
|
||||||
fun getPath(cfi: String): String = cfi.split(':').first()
|
fun getPath(cfi: String): String = cfi.split(':').first()
|
||||||
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
|
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
|
||||||
|
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
|
||||||
|
|
||||||
|
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
|
||||||
|
val candidateParts = pathParts(candidate) ?: return false
|
||||||
|
val startParts = pathParts(start) ?: return false
|
||||||
|
val endParts = pathParts(end) ?: return false
|
||||||
|
return comparePathParts(candidateParts, startParts) > 0 &&
|
||||||
|
comparePathParts(candidateParts, endParts) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pathParts(cfi: String): List<Int>? {
|
||||||
|
val segments = getPath(cfi).split('/').filter { it.isNotEmpty() }
|
||||||
|
if (segments.isEmpty()) return null
|
||||||
|
return segments.map { it.toIntOrNull() ?: return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun comparePathParts(first: List<Int>, second: List<Int>): Int {
|
||||||
|
val length = minOf(first.size, second.size)
|
||||||
|
for (index in 0 until length) {
|
||||||
|
val cmp = first[index].compareTo(second[index])
|
||||||
|
if (cmp != 0) return cmp
|
||||||
|
}
|
||||||
|
return first.size.compareTo(second.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun highlightQueryInText(
|
private fun highlightQueryInText(
|
||||||
|
|
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
|
||||||
effectiveText: Color,
|
effectiveText: Color,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
isPageTurnAnimationEnabled: Boolean,
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
|
isRightToLeftPagination: Boolean = false,
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
fontSizeMultiplier: Float,
|
fontSizeMultiplier: Float,
|
||||||
lineHeightMultiplier: Float,
|
lineHeightMultiplier: Float,
|
||||||
|
|
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
|
||||||
ttsHighlightInfo: TtsHighlightInfo?,
|
ttsHighlightInfo: TtsHighlightInfo?,
|
||||||
initialChapterIndexInBook: Int?,
|
initialChapterIndexInBook: Int?,
|
||||||
fallbackLocatorForReconfiguration: Locator? = null,
|
fallbackLocatorForReconfiguration: Locator? = null,
|
||||||
|
explicitNavigationAnchor: Locator? = null,
|
||||||
|
explicitNavigationEpoch: Long = 0L,
|
||||||
|
isExternalNavigationInProgress: Boolean = false,
|
||||||
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
|
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
|
||||||
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
|
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
|
||||||
onPaginatorReady: (IPaginator) -> Unit,
|
onPaginatorReady: (IPaginator) -> Unit,
|
||||||
|
|
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
|
||||||
onStartTtsFromSelection: (String, Int) -> Unit,
|
onStartTtsFromSelection: (String, Int) -> Unit,
|
||||||
onNoteRequested: (String?) -> Unit,
|
onNoteRequested: (String?) -> Unit,
|
||||||
onFootnoteRequested: (String) -> Unit,
|
onFootnoteRequested: (String) -> Unit,
|
||||||
|
onInternalLinkNavigated: (Int) -> Unit = {},
|
||||||
userHighlights: List<UserHighlight>,
|
userHighlights: List<UserHighlight>,
|
||||||
onHighlightCreated: (String, String, String) -> Unit,
|
onHighlightCreated: (String, String, String) -> Unit,
|
||||||
onHighlightDeleted: (String) -> Unit,
|
onHighlightDeleted: (String) -> Unit,
|
||||||
|
|
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
|
||||||
} else Modifier
|
} else Modifier
|
||||||
|
|
||||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||||
|
var localExplicitNavigationAnchor by remember { mutableStateOf<Locator?>(null) }
|
||||||
|
var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) }
|
||||||
|
val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor)
|
||||||
|
val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch)
|
||||||
|
val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress)
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
||||||
val textMeasurer = rememberTextMeasurer()
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
|
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
|
||||||
|
|
||||||
LaunchedEffect(paginator, pagerState) {
|
LaunchedEffect(paginator, pagerState) {
|
||||||
paginator.pageShiftRequest.collect { shiftAmount ->
|
paginator.pageShiftRequest.collect { shiftAmount ->
|
||||||
val anchor = resolvePaginatedReconfigurationAnchor(
|
if (pagerState.pageCount <= 0) {
|
||||||
currentPageLocator = anchorLocatorForReconfig,
|
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||||
fallbackLocator = latestFallbackLocatorForReconfiguration
|
.w("shift_drop reason=emptyPager shift=$shiftAmount")
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val bookPaginator = paginator as? BookPaginator
|
||||||
|
val currentPageBeforeShift = pagerState.currentPage
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
|
||||||
|
now - latestExternalNavigationEpoch
|
||||||
|
} else {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
|
||||||
|
now - localExplicitNavigationEpoch
|
||||||
|
} else {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
val recentExternalNavigation =
|
||||||
|
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||||
|
val recentLocalNavigation =
|
||||||
|
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||||
|
val activeExplicitAnchor = when {
|
||||||
|
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
|
||||||
|
isNavigatingByLink -> localExplicitNavigationAnchor
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val recentExplicitAnchor = when {
|
||||||
|
recentExternalNavigation -> latestExternalNavigationAnchor
|
||||||
|
recentLocalNavigation -> localExplicitNavigationAnchor
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val activeExplicitAnchorSource = when {
|
||||||
|
activeExplicitAnchor == null -> null
|
||||||
|
latestIsExternalNavigationInProgress -> "explicit_external_active"
|
||||||
|
else -> "explicit_link"
|
||||||
|
}
|
||||||
|
val recentExplicitAnchorSource = when {
|
||||||
|
recentExplicitAnchor == null -> null
|
||||||
|
recentExternalNavigation -> "explicit_external_recent"
|
||||||
|
else -> "explicit_link_recent"
|
||||||
|
}
|
||||||
|
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
|
||||||
|
val fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||||
|
var anchorSource = "none"
|
||||||
|
val anchor = when {
|
||||||
|
anchorLocatorForReconfig != null -> {
|
||||||
|
anchorSource = "reconfiguration"
|
||||||
|
anchorLocatorForReconfig
|
||||||
|
}
|
||||||
|
activeExplicitAnchor != null -> {
|
||||||
|
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
|
||||||
|
activeExplicitAnchor
|
||||||
|
}
|
||||||
|
fallbackLocator != null -> {
|
||||||
|
anchorSource = "last_known"
|
||||||
|
fallbackLocator
|
||||||
|
}
|
||||||
|
recentExplicitAnchor != null -> {
|
||||||
|
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
|
||||||
|
recentExplicitAnchor
|
||||||
|
}
|
||||||
|
currentPageLocator != null -> {
|
||||||
|
anchorSource = "current_page"
|
||||||
|
currentPageLocator
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
|
||||||
)
|
)
|
||||||
|
|
||||||
val resolvedPage = anchor?.let { locator ->
|
val resolvedPage = anchor?.let { locator ->
|
||||||
(paginator as? BookPaginator)?.findPageForLocator(locator)
|
bookPaginator?.findStablePageForLocator(locator)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedPage != null) {
|
if (resolvedPage != null) {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
|
||||||
|
)
|
||||||
pagerState.scrollToPage(resolvedPage)
|
pagerState.scrollToPage(resolvedPage)
|
||||||
paginator.onUserScrolledTo(resolvedPage)
|
paginator.onUserScrolledTo(resolvedPage)
|
||||||
} else {
|
} else {
|
||||||
val newPage = pagerState.currentPage + shiftAmount
|
val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
|
||||||
|
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||||
|
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
|
||||||
|
)
|
||||||
pagerState.scrollToPage(newPage)
|
pagerState.scrollToPage(newPage)
|
||||||
paginator.onUserScrolledTo(newPage)
|
paginator.onUserScrolledTo(newPage)
|
||||||
}
|
}
|
||||||
|
|
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||||
|
isRightToLeftPagination = isRightToLeftPagination,
|
||||||
effectiveBg = effectiveBg,
|
effectiveBg = effectiveBg,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
ttsHighlightInfo = ttsHighlightInfo,
|
ttsHighlightInfo = ttsHighlightInfo,
|
||||||
|
|
@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onInternalLinkNavigated = onInternalLinkNavigated,
|
||||||
onLinkClick = { currentChapterPath, href, onNavComplete ->
|
onLinkClick = { currentChapterPath, href, onNavComplete ->
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isNavigatingByLink = true
|
withContext(Dispatchers.Main) { isNavigatingByLink = true }
|
||||||
var isFootnote = false
|
try {
|
||||||
var footnoteHtml: String? = null
|
var isFootnote = false
|
||||||
|
var footnoteHtml: String? = null
|
||||||
|
|
||||||
val sourceChapter =
|
val sourceChapter =
|
||||||
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
||||||
if (sourceChapter != null) {
|
if (sourceChapter != null) {
|
||||||
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
||||||
try {
|
|
||||||
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
|
||||||
.readText()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sourceHtml.isNotEmpty()) {
|
|
||||||
val doc = Jsoup.parse(sourceHtml)
|
|
||||||
val safeHref = href.replace("\"", "\\\"")
|
|
||||||
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
|
||||||
|
|
||||||
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
|
|
||||||
isFootnote = true
|
|
||||||
}
|
|
||||||
} else if (href.startsWith("#")) {
|
|
||||||
isFootnote = true
|
|
||||||
}
|
|
||||||
} else if (href.startsWith("#")) {
|
|
||||||
isFootnote = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFootnote) {
|
|
||||||
val decodedHref = try {
|
|
||||||
URLDecoder.decode(href, "UTF-8")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
href
|
|
||||||
}
|
|
||||||
val parts = decodedHref.split('#', limit = 2)
|
|
||||||
val pathPart = parts[0]
|
|
||||||
val anchor = if (parts.size > 1) parts[1] else null
|
|
||||||
|
|
||||||
if (anchor != null) {
|
|
||||||
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
|
||||||
try {
|
try {
|
||||||
URI(currentChapterPath).resolve(pathPart)
|
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
||||||
.normalize().path
|
.readText()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (sourceHtml.isNotEmpty()) {
|
||||||
|
val doc = Jsoup.parse(sourceHtml)
|
||||||
|
val safeHref = href.replace("\"", "\\\"")
|
||||||
|
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
||||||
|
|
||||||
if (targetPath != null) {
|
val linkType = aTag?.attr("epub:type").orEmpty()
|
||||||
val targetChapter = book.chaptersForPagination.find {
|
val linkRole = aTag?.attr("role").orEmpty()
|
||||||
|
if (
|
||||||
|
linkType.contains("noteref", ignoreCase = true) ||
|
||||||
|
linkRole.contains("doc-noteref", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
isFootnote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run {
|
||||||
|
val decodedHref = try {
|
||||||
|
URLDecoder.decode(href, "UTF-8")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
href
|
||||||
|
}
|
||||||
|
val parts = decodedHref.split('#', limit = 2)
|
||||||
|
val pathPart = parts[0]
|
||||||
|
val anchor = if (parts.size > 1) parts[1] else null
|
||||||
|
|
||||||
|
if (anchor != null) {
|
||||||
|
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
||||||
try {
|
try {
|
||||||
URI(it.absPath).normalize().path == targetPath
|
URI(currentChapterPath).resolve(pathPart)
|
||||||
|
.normalize().path
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetChapter != null) {
|
if (targetPath != null) {
|
||||||
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
val targetChapter = book.chaptersForPagination.find {
|
||||||
try {
|
try {
|
||||||
File(
|
URI(it.absPath).normalize().path == targetPath
|
||||||
book.extractionBasePath,
|
|
||||||
targetChapter.htmlFilePath
|
|
||||||
).readText()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
""
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetHtml.isNotEmpty()) {
|
|
||||||
val doc = Jsoup.parse(targetHtml)
|
if (targetChapter != null) {
|
||||||
val noteEl = doc.getElementById(anchor)
|
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
||||||
if (noteEl != null) {
|
try {
|
||||||
footnoteHtml = noteEl.html()
|
File(
|
||||||
|
book.extractionBasePath,
|
||||||
|
targetChapter.htmlFilePath
|
||||||
|
).readText()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetHtml.isNotEmpty()) {
|
||||||
|
val doc = Jsoup.parse(targetHtml)
|
||||||
|
val noteEl = doc.getElementById(anchor)
|
||||||
|
if (noteEl != null) {
|
||||||
|
val targetType = noteEl.attr("epub:type")
|
||||||
|
val targetRole = noteEl.attr("role")
|
||||||
|
val targetClass = noteEl.className()
|
||||||
|
val targetLooksLikeFootnote =
|
||||||
|
targetType.contains("footnote", ignoreCase = true) ||
|
||||||
|
targetRole.contains("doc-footnote", ignoreCase = true) ||
|
||||||
|
targetClass.contains("footnote", ignoreCase = true)
|
||||||
|
if (isFootnote || targetLooksLikeFootnote) {
|
||||||
|
footnoteHtml = noteEl.html()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (!footnoteHtml.isNullOrBlank()) {
|
if (!footnoteHtml.isNullOrBlank()) {
|
||||||
onFootnoteRequested(footnoteHtml)
|
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
|
||||||
isNavigatingByLink = false
|
|
||||||
} else {
|
} else {
|
||||||
paginator.navigateToHref(currentChapterPath, href) {
|
val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
|
||||||
onNavComplete(it)
|
withContext(Dispatchers.Main) {
|
||||||
isNavigatingByLink = false
|
if (targetPage != null) {
|
||||||
|
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
|
||||||
|
val navigationEpoch = System.currentTimeMillis()
|
||||||
|
localExplicitNavigationAnchor = targetAnchor
|
||||||
|
localExplicitNavigationEpoch = navigationEpoch
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
|
||||||
|
)
|
||||||
|
paginator.onUserScrolledTo(targetPage)
|
||||||
|
onNavComplete(targetPage)
|
||||||
|
} else {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||||
|
"link_failed href=$href currentChapterPath=$currentChapterPath"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(Dispatchers.Main) { isNavigatingByLink = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getHighlightOffsetsInBlock(
|
internal fun getHighlightOffsetsInBlock(
|
||||||
block: TextContentBlock, highlight: UserHighlight
|
block: TextContentBlock, highlight: UserHighlight
|
||||||
): IntRange? {
|
): IntRange? {
|
||||||
if (block.cfi == null) return null
|
if (block.cfi == null) return null
|
||||||
|
|
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
|
||||||
val parts = highlight.cfi.split('|')
|
val parts = highlight.cfi.split('|')
|
||||||
val startCfi = parts.firstOrNull() ?: highlight.cfi
|
val startCfi = parts.firstOrNull() ?: highlight.cfi
|
||||||
val endCfi = parts.lastOrNull()
|
val endCfi = parts.lastOrNull()
|
||||||
|
val isMultipartHighlight = endCfi != null && endCfi != startCfi
|
||||||
|
|
||||||
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
|
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
|
||||||
is ParagraphBlock -> block.startCharOffsetInSource
|
is ParagraphBlock -> block.startCharOffsetInSource
|
||||||
|
|
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
|
||||||
is ListItemBlock -> block.startCharOffsetInSource
|
is ListItemBlock -> block.startCharOffsetInSource
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
val blockEndAbs = block.endCharOffsetInSource
|
||||||
|
.takeIf { it > blockStartAbs }
|
||||||
|
?: (blockStartAbs + block.content.text.length)
|
||||||
|
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
||||||
|
|
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAfterStart = false
|
|
||||||
var isBeforeEnd = true
|
|
||||||
|
|
||||||
if (relevantPart == null) {
|
|
||||||
if (startCfi.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
|
|
||||||
isAfterStart = true
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endCfi != null && endCfi != startCfi) {
|
|
||||||
try {
|
|
||||||
val endPath = CfiUtils.getPath(endCfi)
|
|
||||||
val cmp = CfiUtils.compare(blockPath, endPath)
|
|
||||||
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
|
|
||||||
if (CfiUtils.compare(blockPath, endPath) > 0) {
|
|
||||||
isBeforeEnd = false
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
|
|
||||||
|
|
||||||
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val blockText = block.content.text
|
val blockText = block.content.text
|
||||||
val highlightText = highlight.text
|
val highlightText = highlight.text
|
||||||
|
|
||||||
if (blockText.isEmpty() || highlightText.isEmpty()) return null
|
if (blockText.isEmpty() || highlightText.isEmpty()) return null
|
||||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
|
||||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
|
||||||
|
|
||||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
val isIntermediateBlock = relevantPart == null &&
|
||||||
if (startIndex == -1) {
|
isMultipartHighlight &&
|
||||||
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
||||||
|
|
||||||
|
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
|
||||||
|
|
||||||
|
if (relevantPart == null) {
|
||||||
|
if (!isIntermediateBlock) return null
|
||||||
|
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||||
|
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||||
|
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||||
|
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||||
|
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||||
|
0 until blockText.length
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relevantPart != null) {
|
if (relevantPart != null) {
|
||||||
|
|
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
|
||||||
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
||||||
|
|
||||||
if (startMatches || endMatches) {
|
if (startMatches || endMatches) {
|
||||||
|
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
||||||
|
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
||||||
|
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
||||||
|
val rangeStartAbs = minOf(startAbs, endAbs)
|
||||||
|
val rangeEndAbs = maxOf(startAbs, endAbs)
|
||||||
|
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
||||||
|
Timber.d(
|
||||||
|
" -> Skipping same-path split block outside highlight offsets. " +
|
||||||
|
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
|
||||||
|
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
|
||||||
|
}
|
||||||
var s = 0
|
var s = 0
|
||||||
var e = blockText.length
|
var e = blockText.length
|
||||||
|
|
||||||
if (startMatches) {
|
if (startMatches) {
|
||||||
val absOffset = CfiUtils.getOffset(startCfi)
|
val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
|
||||||
val relOffset = absOffset - blockStartAbs
|
val relOffset = absOffset - blockStartAbs
|
||||||
|
|
||||||
if (relOffset < 0) {
|
if (relOffset < 0) {
|
||||||
|
|
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endMatches) {
|
if (endMatches) {
|
||||||
val absOffset = CfiUtils.getOffset(endCfi!!)
|
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
||||||
val relOffset = absOffset - blockStartAbs
|
val relOffset = absOffset - blockStartAbs
|
||||||
|
|
||||||
Timber.d(
|
Timber.d(
|
||||||
|
|
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startIndex >= 0) {
|
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||||
return startIndex until (startIndex + highlightText.length)
|
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||||
|
|
||||||
|
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||||
|
if (startIndex == -1) {
|
||||||
|
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relevantPart == null) {
|
if (startIndex >= 0) {
|
||||||
@Suppress("KotlinConstantConditions") if (isAfterStart) {
|
return startIndex until (startIndex + highlightText.length)
|
||||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
|
||||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
|
||||||
if (normHighlight.contains(normBlock, ignoreCase = true)) {
|
|
||||||
return 0 until blockText.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val match = findFuzzyMatch(blockText, highlightText)
|
val match = findFuzzyMatch(blockText, highlightText)
|
||||||
|
|
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
|
||||||
uiState: PaginatedReaderUiState,
|
uiState: PaginatedReaderUiState,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
isPageTurnAnimationEnabled: Boolean,
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
|
isRightToLeftPagination: Boolean = false,
|
||||||
effectiveBg: Color,
|
effectiveBg: Color,
|
||||||
effectiveText: Color,
|
effectiveText: Color,
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
|
|
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
|
||||||
onGetPage: (Int) -> Page?,
|
onGetPage: (Int) -> Page?,
|
||||||
onGetChapterPath: (Int) -> String?,
|
onGetChapterPath: (Int) -> String?,
|
||||||
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
||||||
|
onInternalLinkNavigated: (Int) -> Unit,
|
||||||
onTap: (Offset?) -> Unit,
|
onTap: (Offset?) -> Unit,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
isOss: Boolean,
|
isOss: Boolean,
|
||||||
|
|
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beyondViewportPageCount = 1
|
beyondViewportPageCount = 1,
|
||||||
|
reverseLayout = isRightToLeftPagination
|
||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
val pageOffset =
|
val pageOffset =
|
||||||
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
|
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
|
||||||
} else {
|
} else {
|
||||||
currentChapterPath?.let { path ->
|
currentChapterPath?.let { path ->
|
||||||
onLinkClick(path, href) { targetPageIndex ->
|
onLinkClick(path, href) { targetPageIndex ->
|
||||||
|
onInternalLinkNavigated(targetPageIndex)
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||||
|
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
|
||||||
|
)
|
||||||
pagerState.scrollToPage(targetPageIndex)
|
pagerState.scrollToPage(targetPageIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
internal fun SpanStyle.withReaderLinkStyle(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
themeBackgroundColor: Color,
|
||||||
|
themeTextColor: Color
|
||||||
|
): SpanStyle {
|
||||||
|
val linkStyle = readerLinkSpanStyle(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
|
themeTextColor = themeTextColor,
|
||||||
|
existingDecoration = textDecoration
|
||||||
|
)
|
||||||
|
return copy(
|
||||||
|
color = linkStyle.color,
|
||||||
|
background = linkStyle.background,
|
||||||
|
textDecoration = linkStyle.textDecoration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun readerLinkSpanStyle(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
themeBackgroundColor: Color,
|
||||||
|
themeTextColor: Color,
|
||||||
|
existingDecoration: TextDecoration? = null
|
||||||
|
): SpanStyle {
|
||||||
|
val background = themeBackgroundColor.takeIf { it.isSpecified }
|
||||||
|
?: if (isDarkTheme) Color.Black else Color.White
|
||||||
|
val text = themeTextColor.takeIf { it.isSpecified }
|
||||||
|
?: if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val linkColor = readerLinkColorForTheme(isDarkTheme, background, text)
|
||||||
|
val backgroundAlpha = if (background.safeLuminance() < 0.45f) 0.24f else 0.16f
|
||||||
|
return SpanStyle(
|
||||||
|
color = linkColor,
|
||||||
|
background = linkColor.copy(alpha = backgroundAlpha),
|
||||||
|
textDecoration = existingDecoration.withUnderline()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readerLinkColorForTheme(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
background: Color,
|
||||||
|
text: Color
|
||||||
|
): Color {
|
||||||
|
val backgroundLuminance = background.safeLuminance()
|
||||||
|
val textLuminance = text.safeLuminance()
|
||||||
|
val candidates = if (isDarkTheme || backgroundLuminance < 0.45f) {
|
||||||
|
listOf(
|
||||||
|
Color(0xFF7DD3FC),
|
||||||
|
Color(0xFF5EEAD4),
|
||||||
|
Color(0xFFA5B4FC),
|
||||||
|
Color(0xFFFDE68A),
|
||||||
|
Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
Color(0xFF005FCC),
|
||||||
|
Color(0xFF006D75),
|
||||||
|
Color(0xFF7A1E52),
|
||||||
|
Color(0xFF4A148C),
|
||||||
|
Color(0xFF111827)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidates.firstOrNull {
|
||||||
|
it.contrastRatio(background) >= 4.5f && abs(it.safeLuminance() - textLuminance) >= 0.08f
|
||||||
|
} ?: candidates.maxByOrNull { it.contrastRatio(background) }
|
||||||
|
?: if (isDarkTheme) Color(0xFF7DD3FC) else Color(0xFF005FCC)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextDecoration?.withUnderline(): TextDecoration {
|
||||||
|
val current = this ?: TextDecoration.None
|
||||||
|
val decorations = mutableListOf<TextDecoration>()
|
||||||
|
if (current.contains(TextDecoration.LineThrough)) decorations += TextDecoration.LineThrough
|
||||||
|
decorations += TextDecoration.Underline
|
||||||
|
return TextDecoration.combine(decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.contrastRatio(other: Color): Float {
|
||||||
|
val lighter = maxOf(safeLuminance(), other.safeLuminance())
|
||||||
|
val darker = minOf(safeLuminance(), other.safeLuminance())
|
||||||
|
return (lighter + 0.05f) / (darker + 0.05f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.safeLuminance(): Float {
|
||||||
|
return if (isSpecified) luminance() else 0f
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay(
|
||||||
}
|
}
|
||||||
addStringAnnotation(range.tag, item, range.start, range.end)
|
addStringAnnotation(range.tag, item, range.start, range.end)
|
||||||
}
|
}
|
||||||
|
this@applyReaderThemeForDisplay.getStringAnnotations("URL", 0, this@applyReaderThemeForDisplay.length).forEach { range ->
|
||||||
|
addStyle(
|
||||||
|
readerLinkSpanStyle(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
|
themeTextColor = themeTextColor
|
||||||
|
),
|
||||||
|
range.start,
|
||||||
|
range.end
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
internal suspend fun resolveStableChapterStartPage(
|
||||||
|
chapterIndex: Int,
|
||||||
|
chapterCount: Int,
|
||||||
|
pageCountsAreAccurate: Boolean,
|
||||||
|
chapterStartPage: (Int) -> Int?,
|
||||||
|
isChapterFinalized: (Int) -> Boolean,
|
||||||
|
ensureChapterPaginated: suspend (Int) -> Boolean
|
||||||
|
): Int? {
|
||||||
|
if (chapterIndex !in 0 until chapterCount) return null
|
||||||
|
|
||||||
|
if (!pageCountsAreAccurate) {
|
||||||
|
for (prefixChapter in 0 until chapterIndex) {
|
||||||
|
if (!isChapterFinalized(prefixChapter)) {
|
||||||
|
val ready = ensureChapterPaginated(prefixChapter)
|
||||||
|
if (!ready) return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapterStartPage(chapterIndex) ?: if (chapterIndex == 0) 0 else null
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
|
@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal data class MagnifierContentSource(
|
||||||
|
val sourceWidth: Int,
|
||||||
|
val sourceHeight: Int,
|
||||||
|
val contentLeft: Float,
|
||||||
|
val contentTop: Float,
|
||||||
|
val contentWidth: Float,
|
||||||
|
val contentHeight: Float
|
||||||
|
) {
|
||||||
|
val scaleX: Float
|
||||||
|
get() = if (contentWidth > 0f) sourceWidth.toFloat() / contentWidth else 1f
|
||||||
|
|
||||||
|
val scaleY: Float
|
||||||
|
get() = if (contentHeight > 0f) sourceHeight.toFloat() / contentHeight else 1f
|
||||||
|
|
||||||
|
fun sourceX(contentX: Float): Float = (contentX - contentLeft) * scaleX
|
||||||
|
|
||||||
|
fun sourceY(contentY: Float): Float = (contentY - contentTop) * scaleY
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class MagnifierSampleGeometry(
|
||||||
|
val srcLeft: Int,
|
||||||
|
val srcTop: Int,
|
||||||
|
val srcWidth: Int,
|
||||||
|
val srcHeight: Int,
|
||||||
|
val outputScaleX: Float,
|
||||||
|
val outputScaleY: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun calculateMagnifierSampleGeometry(
|
||||||
|
centerContentX: Float,
|
||||||
|
centerContentY: Float,
|
||||||
|
contentSource: MagnifierContentSource,
|
||||||
|
magnifierWidthPx: Float,
|
||||||
|
magnifierHeightPx: Float,
|
||||||
|
zoomFactor: Float
|
||||||
|
): MagnifierSampleGeometry? {
|
||||||
|
if (
|
||||||
|
contentSource.sourceWidth <= 0 ||
|
||||||
|
contentSource.sourceHeight <= 0 ||
|
||||||
|
contentSource.contentWidth <= 0f ||
|
||||||
|
contentSource.contentHeight <= 0f ||
|
||||||
|
magnifierWidthPx <= 0f ||
|
||||||
|
magnifierHeightPx <= 0f ||
|
||||||
|
zoomFactor <= 0f
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceCenterX = contentSource.sourceX(centerContentX)
|
||||||
|
val sourceCenterY = contentSource.sourceY(centerContentY)
|
||||||
|
val sourceRectWidth = (magnifierWidthPx / zoomFactor * contentSource.scaleX).coerceAtLeast(1f)
|
||||||
|
val sourceRectHeight = (magnifierHeightPx / zoomFactor * contentSource.scaleY).coerceAtLeast(1f)
|
||||||
|
|
||||||
|
val maxSrcLeft = max(0f, contentSource.sourceWidth.toFloat() - sourceRectWidth)
|
||||||
|
val maxSrcTop = max(0f, contentSource.sourceHeight.toFloat() - sourceRectHeight)
|
||||||
|
val srcLeft = (sourceCenterX - sourceRectWidth / 2f).coerceIn(0f, maxSrcLeft)
|
||||||
|
val srcTop = (sourceCenterY - sourceRectHeight / 2f).coerceIn(0f, maxSrcTop)
|
||||||
|
|
||||||
|
val srcLeftInt = srcLeft.roundToInt().coerceIn(0, contentSource.sourceWidth - 1)
|
||||||
|
val srcTopInt = srcTop.roundToInt().coerceIn(0, contentSource.sourceHeight - 1)
|
||||||
|
val srcWidthInt = (contentSource.sourceWidth - srcLeftInt)
|
||||||
|
.coerceAtMost(sourceRectWidth.roundToInt().coerceAtLeast(1))
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val srcHeightInt = (contentSource.sourceHeight - srcTopInt)
|
||||||
|
.coerceAtMost(sourceRectHeight.roundToInt().coerceAtLeast(1))
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
|
||||||
|
return MagnifierSampleGeometry(
|
||||||
|
srcLeft = srcLeftInt,
|
||||||
|
srcTop = srcTopInt,
|
||||||
|
srcWidth = srcWidthInt,
|
||||||
|
srcHeight = srcHeightInt,
|
||||||
|
outputScaleX = magnifierWidthPx / srcWidthInt,
|
||||||
|
outputScaleY = magnifierHeightPx / srcHeightInt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapContentRectToMagnifier(
|
||||||
|
contentRect: Rect,
|
||||||
|
contentSource: MagnifierContentSource,
|
||||||
|
sample: MagnifierSampleGeometry
|
||||||
|
): RectF {
|
||||||
|
val sourceLeft = contentSource.sourceX(contentRect.left.toFloat())
|
||||||
|
val sourceTop = contentSource.sourceY(contentRect.top.toFloat())
|
||||||
|
val sourceRight = contentSource.sourceX(contentRect.right.toFloat())
|
||||||
|
val sourceBottom = contentSource.sourceY(contentRect.bottom.toFloat())
|
||||||
|
|
||||||
|
return RectF(
|
||||||
|
(sourceLeft - sample.srcLeft) * sample.outputScaleX,
|
||||||
|
(sourceTop - sample.srcTop) * sample.outputScaleY,
|
||||||
|
(sourceRight - sample.srcLeft) * sample.outputScaleX,
|
||||||
|
(sourceBottom - sample.srcTop) * sample.outputScaleY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MagnifierComposable(
|
fun MagnifierComposable(
|
||||||
sourceBitmap: ImageBitmap,
|
sourceBitmap: ImageBitmap,
|
||||||
tiles: List<PdfTile>,
|
tiles: List<PdfTile>,
|
||||||
currentScale: Float,
|
currentScale: Float,
|
||||||
magnifierCenterOnBitmap: Offset,
|
magnifierCenterOnBitmap: Offset,
|
||||||
|
contentWidthPx: Int = sourceBitmap.width,
|
||||||
|
contentHeightPx: Int = sourceBitmap.height,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
magnifierWidth: Dp = 120.dp,
|
magnifierWidth: Dp = 120.dp,
|
||||||
magnifierHeight: Dp = 60.dp,
|
magnifierHeight: Dp = 60.dp,
|
||||||
zoomFactor: Float = 1.5f,
|
zoomFactor: Float = 1.5f,
|
||||||
selectionRectsInBitmapCoords: List<Rect>,
|
selectionRectsInContentCoords: List<Rect>,
|
||||||
highlightColor: Color,
|
highlightColor: Color,
|
||||||
colorFilter: ColorFilter? = null
|
colorFilter: ColorFilter? = null
|
||||||
) {
|
) {
|
||||||
|
|
@ -81,155 +180,71 @@ fun MagnifierComposable(
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
if (relevantTile != null) {
|
val bitmapToUse: ImageBitmap
|
||||||
// --- HIGH-RES TILE PATH ---
|
val contentSource: MagnifierContentSource
|
||||||
|
if (relevantTile != null && !relevantTile.bitmap.isRecycled) {
|
||||||
Timber.d("Magnifier: Using HIGH-RES TILE path.")
|
Timber.d("Magnifier: Using HIGH-RES TILE path.")
|
||||||
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
|
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
|
||||||
val bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
||||||
|
contentSource = MagnifierContentSource(
|
||||||
val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
|
sourceWidth = bitmapToUse.width,
|
||||||
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
|
sourceHeight = bitmapToUse.height,
|
||||||
|
contentLeft = relevantTile.renderRect.left.toFloat(),
|
||||||
val tileScale = if (tileRenderRectWidth > 0) {
|
contentTop = relevantTile.renderRect.top.toFloat(),
|
||||||
tileBitmapWidth / tileRenderRectWidth
|
contentWidth = relevantTile.renderRect.width().toFloat(),
|
||||||
} else {
|
contentHeight = relevantTile.renderRect.height().toFloat()
|
||||||
1f
|
|
||||||
}
|
|
||||||
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
|
|
||||||
|
|
||||||
|
|
||||||
val centerInTileBitmap = Offset(
|
|
||||||
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
|
|
||||||
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
|
|
||||||
|
|
||||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
|
||||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
|
||||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
|
||||||
|
|
||||||
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
|
|
||||||
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
|
||||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
|
||||||
|
|
||||||
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
|
||||||
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
|
||||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
|
||||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
|
||||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
|
||||||
|
|
||||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
|
||||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
|
||||||
|
|
||||||
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
|
|
||||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
|
||||||
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
|
|
||||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
|
||||||
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
|
||||||
|
|
||||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
|
|
||||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
|
||||||
return@Canvas
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImage(
|
|
||||||
image = bitmapToUse,
|
|
||||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
|
||||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
|
||||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
|
||||||
colorFilter = colorFilter
|
|
||||||
)
|
|
||||||
|
|
||||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
|
||||||
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
|
|
||||||
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
|
|
||||||
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
|
|
||||||
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
|
|
||||||
|
|
||||||
val finalLeft = translatedLeft - clampedSrcLeft
|
|
||||||
val finalTop = translatedTop - clampedSrcTop
|
|
||||||
val finalRight = translatedRight - clampedSrcLeft
|
|
||||||
val finalBottom = translatedBottom - clampedSrcTop
|
|
||||||
|
|
||||||
val magnifiedLeft = finalLeft * zoomFactor
|
|
||||||
val magnifiedTop = finalTop * zoomFactor
|
|
||||||
val magnifiedRight = finalRight * zoomFactor
|
|
||||||
val magnifiedBottom = finalBottom * zoomFactor
|
|
||||||
|
|
||||||
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
|
|
||||||
drawRect(
|
|
||||||
color = highlightColor,
|
|
||||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
|
||||||
size = androidx.compose.ui.geometry.Size(
|
|
||||||
width = magnifiedRight - magnifiedLeft,
|
|
||||||
height = magnifiedBottom - magnifiedTop
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// --- LOW-RES / NO-ZOOM PATH ---
|
|
||||||
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
|
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
|
||||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
bitmapToUse = sourceBitmap
|
||||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
contentSource = MagnifierContentSource(
|
||||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
sourceWidth = sourceBitmap.width,
|
||||||
|
sourceHeight = sourceBitmap.height,
|
||||||
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
|
contentLeft = 0f,
|
||||||
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
contentTop = 0f,
|
||||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
contentWidth = contentWidthPx.toFloat(),
|
||||||
|
contentHeight = contentHeightPx.toFloat()
|
||||||
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
|
||||||
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
|
||||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
|
||||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
|
||||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
|
||||||
|
|
||||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
|
||||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
|
||||||
|
|
||||||
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
|
|
||||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
|
||||||
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
|
|
||||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
|
||||||
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
|
||||||
|
|
||||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
|
|
||||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
|
||||||
return@Canvas
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImage(
|
|
||||||
image = sourceBitmap,
|
|
||||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
|
||||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
|
||||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
|
||||||
colorFilter = colorFilter
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
val sample = calculateMagnifierSampleGeometry(
|
||||||
val translatedLeft = rectInBitmap.left - clampedSrcLeft
|
centerContentX = magnifierCenterOnBitmap.x,
|
||||||
val translatedTop = rectInBitmap.top - clampedSrcTop
|
centerContentY = magnifierCenterOnBitmap.y,
|
||||||
val rectWidthInBitmap = rectInBitmap.width().toFloat()
|
contentSource = contentSource,
|
||||||
val rectHeightInBitmap = rectInBitmap.height().toFloat()
|
magnifierWidthPx = magnifierWidthPx,
|
||||||
|
magnifierHeightPx = magnifierHeightPx,
|
||||||
|
zoomFactor = zoomFactor
|
||||||
|
) ?: run {
|
||||||
|
Timber.w("Magnifier: Source geometry is invalid, returning.")
|
||||||
|
return@Canvas
|
||||||
|
}
|
||||||
|
Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}")
|
||||||
|
|
||||||
val magnifiedLeft = translatedLeft * zoomFactor
|
drawImage(
|
||||||
val magnifiedTop = translatedTop * zoomFactor
|
image = bitmapToUse,
|
||||||
val magnifiedWidth = rectWidthInBitmap * zoomFactor
|
srcOffset = IntOffset(sample.srcLeft, sample.srcTop),
|
||||||
val magnifiedHeight = rectHeightInBitmap * zoomFactor
|
srcSize = IntSize(sample.srcWidth, sample.srcHeight),
|
||||||
|
dstSize = IntSize(
|
||||||
|
magnifierWidthPx.roundToInt().coerceAtLeast(1),
|
||||||
|
magnifierHeightPx.roundToInt().coerceAtLeast(1)
|
||||||
|
),
|
||||||
|
colorFilter = colorFilter
|
||||||
|
)
|
||||||
|
|
||||||
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
|
selectionRectsInContentCoords.forEach { contentRect ->
|
||||||
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
|
val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample)
|
||||||
drawRect(
|
if (magnifierRect.width() > 0f && magnifierRect.height() > 0f &&
|
||||||
color = highlightColor,
|
magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx &&
|
||||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx
|
||||||
size = androidx.compose.ui.geometry.Size(
|
) {
|
||||||
width = magnifiedWidth,
|
drawRect(
|
||||||
height = magnifiedHeight
|
color = highlightColor,
|
||||||
)
|
topLeft = Offset(magnifierRect.left, magnifierRect.top),
|
||||||
|
size = Size(
|
||||||
|
width = magnifierRect.width(),
|
||||||
|
height = magnifierRect.height()
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
Normal file
24
app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
internal const val PDF_BUBBLE_PREFETCH_RADIUS = 1
|
||||||
|
|
||||||
|
internal fun buildPdfBubblePrefetchOrder(
|
||||||
|
currentPage: Int,
|
||||||
|
totalPages: Int,
|
||||||
|
radius: Int = PDF_BUBBLE_PREFETCH_RADIUS
|
||||||
|
): List<Int> {
|
||||||
|
if (totalPages <= 0 || radius < 0) return emptyList()
|
||||||
|
|
||||||
|
val clampedCurrentPage = currentPage.coerceIn(0, totalPages - 1)
|
||||||
|
val ordered = LinkedHashSet<Int>()
|
||||||
|
ordered += clampedCurrentPage
|
||||||
|
|
||||||
|
for (distance in 1..radius) {
|
||||||
|
val next = clampedCurrentPage + distance
|
||||||
|
val previous = clampedCurrentPage - distance
|
||||||
|
if (next in 0 until totalPages) ordered += next
|
||||||
|
if (previous in 0 until totalPages) ordered += previous
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered.toList()
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,14 @@ import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
||||||
|
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
||||||
|
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
||||||
|
|
||||||
object PdfiumCoreProvider {
|
object PdfiumCoreProvider {
|
||||||
val core: PdfiumCoreKt by lazy {
|
val core: PdfiumCoreKt by lazy {
|
||||||
PdfiumCoreKt(Dispatchers.Default)
|
PdfiumCoreKt(Dispatchers.Default)
|
||||||
|
|
@ -31,7 +37,7 @@ object PdfiumCoreProvider {
|
||||||
|
|
||||||
internal data class DocumentCacheItem(
|
internal data class DocumentCacheItem(
|
||||||
val doc: ReaderDocument,
|
val doc: ReaderDocument,
|
||||||
val pfd: ParcelFileDescriptor,
|
val pfd: ParcelFileDescriptor?,
|
||||||
val totalPages: Int,
|
val totalPages: Int,
|
||||||
val pageAspectRatios: List<Float>,
|
val pageAspectRatios: List<Float>,
|
||||||
val flatTableOfContents: List<TocEntry>
|
val flatTableOfContents: List<TocEntry>
|
||||||
|
|
@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) {
|
||||||
if (evicted) {
|
if (evicted) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
||||||
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
|
try { oldValue.pfd?.close() } catch (e: Exception) { Timber.e(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,14 +162,34 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
||||||
page = doc.openPage(pageIndex)
|
page = doc.openPage(pageIndex)
|
||||||
if (page == null) return@withContext null
|
if (page == null) return@withContext null
|
||||||
|
|
||||||
val bitmapWidth = 1080
|
val pageWidth = page.getPageWidthPoint()
|
||||||
|
val pageHeight = page.getPageHeightPoint()
|
||||||
|
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||||
|
Timber.e("Invalid page size for page $pageIndex: ${pageWidth}x${pageHeight}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
val aspectRatio =
|
val aspectRatio =
|
||||||
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
|
pageWidth.toFloat() / pageHeight.toFloat()
|
||||||
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
||||||
Timber.e("Invalid aspect ratio for page $pageIndex")
|
Timber.e("Invalid aspect ratio for page $pageIndex")
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
|
|
||||||
|
var bitmapWidth = PDF_PREVIEW_MAX_WIDTH_PX
|
||||||
|
var bitmapHeight = (bitmapWidth / aspectRatio).roundToInt()
|
||||||
|
|
||||||
|
if (bitmapHeight > PDF_PREVIEW_MAX_HEIGHT_PX) {
|
||||||
|
bitmapHeight = PDF_PREVIEW_MAX_HEIGHT_PX
|
||||||
|
bitmapWidth = (bitmapHeight * aspectRatio).roundToInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestedBytes = bitmapWidth.toLong() * bitmapHeight.toLong() * 4L
|
||||||
|
if (requestedBytes > PDF_PREVIEW_MAX_BYTES) {
|
||||||
|
val scale = sqrt(PDF_PREVIEW_MAX_BYTES.toDouble() / requestedBytes.toDouble())
|
||||||
|
bitmapWidth = (bitmapWidth * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
bitmapHeight = (bitmapHeight * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
if (bitmapHeight <= 0) {
|
if (bitmapHeight <= 0) {
|
||||||
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
||||||
|
|
@ -191,4 +217,4 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
|
import com.aryan.reader.pdf.data.VirtualPage
|
||||||
|
|
||||||
private const val MAX_FIXED_RECURSION = 128
|
private const val MAX_FIXED_RECURSION = 128
|
||||||
|
|
||||||
|
|
@ -281,6 +282,7 @@ internal fun PdfTocTreeItem(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfNavigationDrawerContent(
|
internal fun PdfNavigationDrawerContent(
|
||||||
pdfDocument: ReaderDocument?,
|
pdfDocument: ReaderDocument?,
|
||||||
|
documentKey: String,
|
||||||
flatTableOfContents: List<TocEntry>,
|
flatTableOfContents: List<TocEntry>,
|
||||||
bookmarks: Set<PdfBookmark>,
|
bookmarks: Set<PdfBookmark>,
|
||||||
userHighlights: List<PdfUserHighlight>,
|
userHighlights: List<PdfUserHighlight>,
|
||||||
|
|
@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent(
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
val thumbPageId = remember(documentKey, pageIdx) {
|
||||||
|
pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx))
|
||||||
|
}
|
||||||
|
var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) }
|
||||||
|
|
||||||
LaunchedEffect(pageIdx, pdfDocument) {
|
LaunchedEffect(thumbPageId, pdfDocument) {
|
||||||
if (thumb == null && pdfDocument != null) {
|
if (thumb == null && pdfDocument != null) {
|
||||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val cached = PdfThumbnailCache.get(pageIdx)
|
val cached = PdfThumbnailCache.get(thumbPageId)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
thumb = cached
|
thumb = cached
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent(
|
||||||
val bmp = createBitmap(thumbW, thumbH)
|
val bmp = createBitmap(thumbW, thumbH)
|
||||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||||
PdfThumbnailCache.put(pageIdx, bmp)
|
PdfThumbnailCache.put(thumbPageId, bmp)
|
||||||
thumb = bmp
|
thumb = bmp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ import androidx.core.graphics.scale
|
||||||
import androidx.core.graphics.set
|
import androidx.core.graphics.set
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.isCanvasSafeBitmap
|
||||||
import com.aryan.reader.loadReaderTextureBitmap
|
import com.aryan.reader.loadReaderTextureBitmap
|
||||||
import com.aryan.reader.ml.SpeechBubble
|
import com.aryan.reader.ml.SpeechBubble
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||||
|
|
@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.conflate
|
import kotlinx.coroutines.flow.conflate
|
||||||
|
|
@ -171,6 +171,47 @@ enum class InkType {
|
||||||
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shouldReportPdfPageCamera(
|
||||||
|
isZoomEnabled: Boolean,
|
||||||
|
isVerticalScroll: Boolean,
|
||||||
|
isScrollLocked: Boolean,
|
||||||
|
lockedState: Triple<Float, Float, Float>?,
|
||||||
|
hasAppliedLockedState: Boolean
|
||||||
|
): Boolean {
|
||||||
|
return !isZoomEnabled ||
|
||||||
|
isVerticalScroll ||
|
||||||
|
!isScrollLocked ||
|
||||||
|
lockedState == null ||
|
||||||
|
hasAppliedLockedState
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun initialPdfPageCamera(
|
||||||
|
isZoomEnabled: Boolean,
|
||||||
|
isVerticalScroll: Boolean,
|
||||||
|
isScrollLocked: Boolean,
|
||||||
|
lockedState: Triple<Float, Float, Float>?
|
||||||
|
): Pair<Float, Offset> {
|
||||||
|
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
|
||||||
|
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||||
|
} else {
|
||||||
|
1f to Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||||
|
isBubbleZoomModeActive: Boolean,
|
||||||
|
scale: Float,
|
||||||
|
isVerticalScroll: Boolean,
|
||||||
|
isZoomEnabled: Boolean,
|
||||||
|
isScrollLocked: Boolean
|
||||||
|
): Boolean {
|
||||||
|
return !isBubbleZoomModeActive &&
|
||||||
|
scale > 1f &&
|
||||||
|
!isVerticalScroll &&
|
||||||
|
isZoomEnabled &&
|
||||||
|
!isScrollLocked
|
||||||
|
}
|
||||||
|
|
||||||
data class EmbeddedAnnotation(
|
data class EmbeddedAnnotation(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val subtype: Int,
|
val subtype: Int,
|
||||||
|
|
@ -186,8 +227,25 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
||||||
|
|
||||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||||
|
|
||||||
|
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
|
||||||
|
val sourcePageId = when (virtualPage) {
|
||||||
|
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||||
|
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||||
|
null -> "PDF_$pageIndex"
|
||||||
|
}
|
||||||
|
return "$documentKey:$sourcePageId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.readablePdfErrorDetail(): String {
|
||||||
|
return localizedMessage?.takeIf { it.isNotBlank() }
|
||||||
|
?: javaClass.simpleName.takeIf { it.isNotBlank() }
|
||||||
|
?: "Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
private const val PDF_TILE_SIZE_DP = 256
|
private const val PDF_TILE_SIZE_DP = 256
|
||||||
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||||
|
private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||||
|
private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
||||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
||||||
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
||||||
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||||
|
|
@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap(
|
||||||
}
|
}
|
||||||
|
|
||||||
document.openPage(pageIndex)?.use { page ->
|
document.openPage(pageIndex)?.use { page ->
|
||||||
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
|
val safeRenderScale = safePdfBitmapRenderScale(
|
||||||
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
|
contentWidth = bubbleBounds.width(),
|
||||||
|
contentHeight = bubbleBounds.height(),
|
||||||
|
requestedScale = renderScale
|
||||||
|
)
|
||||||
|
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||||
val bitmap = createBitmap(cropWidth, cropHeight)
|
val bitmap = createBitmap(cropWidth, cropHeight)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
page.renderPageBitmap(
|
page.renderPageBitmap(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
startX = (-bubbleBounds.left * renderScale).roundToInt(),
|
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
|
||||||
startY = (-bubbleBounds.top * renderScale).roundToInt(),
|
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
|
||||||
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
|
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||||
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
|
drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||||
renderAnnot = true
|
renderAnnot = true
|
||||||
)
|
)
|
||||||
bitmap
|
bitmap
|
||||||
|
|
@ -277,6 +340,23 @@ private suspend fun renderExpandedBubbleBitmap(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun safePdfBitmapRenderScale(
|
||||||
|
contentWidth: Float,
|
||||||
|
contentHeight: Float,
|
||||||
|
requestedScale: Float
|
||||||
|
): Float {
|
||||||
|
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
|
||||||
|
|
||||||
|
val requestedWidth = contentWidth * requestedScale
|
||||||
|
val requestedHeight = contentHeight * requestedScale
|
||||||
|
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
|
||||||
|
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
|
||||||
|
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
|
||||||
|
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
|
||||||
|
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||||
|
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
object PdfInkGeometry {
|
object PdfInkGeometry {
|
||||||
fun calculateFountainPenPoints(
|
fun calculateFountainPenPoints(
|
||||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||||
|
|
@ -380,16 +460,15 @@ internal object PdfBitmapPool {
|
||||||
fun get(size: Int): Bitmap = get(size, size)
|
fun get(size: Int): Bitmap = get(size, size)
|
||||||
|
|
||||||
fun recycle(bitmap: Bitmap) {
|
fun recycle(bitmap: Bitmap) {
|
||||||
|
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
|
||||||
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
||||||
pool.offer(bitmap)
|
pool.offer(bitmap)
|
||||||
} else {
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
while (!pool.isEmpty()) {
|
while (!pool.isEmpty()) {
|
||||||
pool.poll()?.recycle()
|
pool.poll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -400,20 +479,20 @@ internal object PdfThumbnailCache {
|
||||||
|
|
||||||
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
||||||
|
|
||||||
private val memoryCache = object : LruCache<Int, CacheEntry>(cacheSize) {
|
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
|
||||||
override fun sizeOf(key: Int, entry: CacheEntry): Int {
|
override fun sizeOf(key: String, entry: CacheEntry): Int {
|
||||||
return entry.sizeKb
|
return entry.sizeKb
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(pageIndex: Int): Bitmap? {
|
fun get(pageId: String): Bitmap? {
|
||||||
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
|
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun put(pageIndex: Int, bitmap: Bitmap) {
|
fun put(pageId: String, bitmap: Bitmap) {
|
||||||
if (get(pageIndex) == null) {
|
if (get(pageId) == null) {
|
||||||
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
||||||
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb))
|
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,6 +554,7 @@ data class PageSelectionData(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfPageComposable(
|
internal fun PdfPageComposable(
|
||||||
pdfDocument: StableHolder<ReaderDocument>,
|
pdfDocument: StableHolder<ReaderDocument>,
|
||||||
|
documentKey: String,
|
||||||
pageIndex: Int,
|
pageIndex: Int,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -507,6 +587,7 @@ internal fun PdfPageComposable(
|
||||||
isScrolling: Boolean = false,
|
isScrolling: Boolean = false,
|
||||||
lazyListState: LazyListState? = null,
|
lazyListState: LazyListState? = null,
|
||||||
isVerticalScroll: Boolean = false,
|
isVerticalScroll: Boolean = false,
|
||||||
|
showPageNumberOverlay: Boolean = true,
|
||||||
visualScaleProvider: () -> Float = { 1f },
|
visualScaleProvider: () -> Float = { 1f },
|
||||||
clearSelectionTrigger: Long = 0L,
|
clearSelectionTrigger: Long = 0L,
|
||||||
resetZoomTrigger: Long = 0L,
|
resetZoomTrigger: Long = 0L,
|
||||||
|
|
@ -558,18 +639,13 @@ internal fun PdfPageComposable(
|
||||||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val pdfDocumentItem = pdfDocument.item
|
val pdfDocumentItem = pdfDocument.item
|
||||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
val targetPageId = remember(documentKey, virtualPage, pageIndex) {
|
||||||
var currentRenderedPageId by remember { mutableStateOf<String?>(null) }
|
pdfRenderPageId(documentKey, pageIndex, virtualPage)
|
||||||
|
|
||||||
val targetPageId = remember(virtualPage, pageIndex) {
|
|
||||||
when (virtualPage) {
|
|
||||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
|
||||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
|
||||||
null -> "PDF_$pageIndex"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var isLoadingPage by remember { mutableStateOf(true) }
|
var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
|
||||||
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
var currentRenderedPageId by remember(targetPageId) { mutableStateOf<String?>(null) }
|
||||||
|
var isLoadingPage by remember(targetPageId) { mutableStateOf(true) }
|
||||||
|
var pageErrorMessage by remember(targetPageId) { mutableStateOf<String?>(null) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val viewConfiguration = LocalViewConfiguration.current
|
val viewConfiguration = LocalViewConfiguration.current
|
||||||
|
|
@ -581,12 +657,30 @@ internal fun PdfPageComposable(
|
||||||
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
|
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
var isTransforming by remember { mutableStateOf(false) }
|
var isTransforming by remember { mutableStateOf(false) }
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
val initialCamera = initialPdfPageCamera(
|
||||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
isZoomEnabled = isZoomEnabled,
|
||||||
|
isVerticalScroll = isVerticalScroll,
|
||||||
|
isScrollLocked = isScrollLocked,
|
||||||
|
lockedState = lockedState
|
||||||
|
)
|
||||||
|
var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) }
|
||||||
|
var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) }
|
||||||
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var hasAppliedLockedPaginationState by remember(targetPageId) {
|
||||||
|
mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f)
|
||||||
|
}
|
||||||
|
val shouldReportCamera = shouldReportPdfPageCamera(
|
||||||
|
isZoomEnabled = isZoomEnabled,
|
||||||
|
isVerticalScroll = isVerticalScroll,
|
||||||
|
isScrollLocked = isScrollLocked,
|
||||||
|
lockedState = lockedState,
|
||||||
|
hasAppliedLockedState = hasAppliedLockedPaginationState
|
||||||
|
)
|
||||||
|
|
||||||
LaunchedEffect(scale, offset) {
|
LaunchedEffect(scale, offset, shouldReportCamera) {
|
||||||
onZoomAndPanChanged?.invoke(scale, offset)
|
if (shouldReportCamera) {
|
||||||
|
onZoomAndPanChanged?.invoke(scale, offset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||||
|
|
@ -609,7 +703,7 @@ internal fun PdfPageComposable(
|
||||||
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
|
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
|
||||||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
||||||
|
|
||||||
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
|
var tiles by remember(targetPageId) { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||||
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
||||||
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
||||||
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
||||||
|
|
@ -645,15 +739,15 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectionCharRange = remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
val selectionCharRange = remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
var activeDraggingHandle by remember { mutableStateOf<Handle?>(null) }
|
var activeDraggingHandle by remember(targetPageId) { mutableStateOf<Handle?>(null) }
|
||||||
var selectedWordScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
var selectedWordScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||||
val startHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
val startHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||||
val endHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
val endHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
|
var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||||
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
|
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||||
var currentPageRotation by remember { mutableIntStateOf(0) }
|
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||||
|
|
||||||
|
|
@ -736,7 +830,7 @@ internal fun PdfPageComposable(
|
||||||
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
|
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
|
||||||
val magnifierZoomFactor = 2.0f
|
val magnifierZoomFactor = 2.0f
|
||||||
|
|
||||||
var customMenuState by remember { mutableStateOf<CustomPdfMenuState?>(null) }
|
var customMenuState by remember(targetPageId) { mutableStateOf<CustomPdfMenuState?>(null) }
|
||||||
|
|
||||||
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
|
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
|
||||||
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
|
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
|
||||||
|
|
@ -826,7 +920,15 @@ internal fun PdfPageComposable(
|
||||||
expandedBubbleIndex = -1
|
expandedBubbleIndex = -1
|
||||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||||
expandedBubbleRender = null
|
expandedBubbleRender = null
|
||||||
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
|
if (
|
||||||
|
shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||||
|
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||||
|
scale = scale,
|
||||||
|
isVerticalScroll = isVerticalScroll,
|
||||||
|
isZoomEnabled = isZoomEnabled,
|
||||||
|
isScrollLocked = isScrollLocked
|
||||||
|
)
|
||||||
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
Animatable(scale).animateTo(1f, tween(300)) {
|
Animatable(scale).animateTo(1f, tween(300)) {
|
||||||
scale = this.value
|
scale = this.value
|
||||||
|
|
@ -881,10 +983,10 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(targetPageId) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val currentBitmap = bitmapState
|
val currentBitmap = bitmapState
|
||||||
val cachedBitmap = PdfThumbnailCache.get(pageIndex)
|
val cachedBitmap = PdfThumbnailCache.get(targetPageId)
|
||||||
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
||||||
currentBitmap.recycle()
|
currentBitmap.recycle()
|
||||||
}
|
}
|
||||||
|
|
@ -895,16 +997,16 @@ internal fun PdfPageComposable(
|
||||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
var ocrVisionTextForSelection by remember { mutableStateOf<OcrResult?>(null) }
|
var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf<OcrResult?>(null) }
|
||||||
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
|
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
|
||||||
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
|
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
|
||||||
var ocrSelectionSymbolIndices by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
var allOcrSymbolsForSelection by remember { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
||||||
|
|
||||||
var highlightedTextScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||||
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
|
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
|
||||||
|
|
||||||
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||||
|
|
||||||
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
|
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
|
@ -937,7 +1039,7 @@ internal fun PdfPageComposable(
|
||||||
val mergedSearchHighlightRects =
|
val mergedSearchHighlightRects =
|
||||||
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
|
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
|
||||||
|
|
||||||
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) }
|
var pageLinks by remember(targetPageId) { mutableStateOf<List<PageLink>>(emptyList()) }
|
||||||
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||||
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
|
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
|
||||||
|
|
||||||
|
|
@ -1091,9 +1193,9 @@ internal fun PdfPageComposable(
|
||||||
onHighlightLoading(false)
|
onHighlightLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
@Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||||
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||||
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
var imageScreenRects by remember(targetPageId) { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||||
|
|
||||||
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
||||||
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
||||||
|
|
@ -1216,84 +1318,80 @@ internal fun PdfPageComposable(
|
||||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
val allAnnots = (0 until count).mapNotNull { i ->
|
||||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||||
if (count > 0) {
|
if (subtype == annotLink) return@mapNotNull null
|
||||||
val allAnnots = (0 until count).mapNotNull { i ->
|
|
||||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
|
||||||
if (subtype == annotLink) return@mapNotNull null
|
|
||||||
|
|
||||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||||
if (contents.isNullOrBlank()) {
|
if (contents.isNullOrBlank()) {
|
||||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||||
}
|
|
||||||
|
|
||||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
|
||||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
|
||||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
|
||||||
|
|
||||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
|
||||||
val pdfRectF = if (pdfRectArray != null) {
|
|
||||||
android.graphics.RectF(
|
|
||||||
min(pdfRectArray[0], pdfRectArray[2]),
|
|
||||||
max(pdfRectArray[1], pdfRectArray[3]),
|
|
||||||
max(pdfRectArray[0], pdfRectArray[2]),
|
|
||||||
min(pdfRectArray[1], pdfRectArray[3])
|
|
||||||
)
|
|
||||||
} else android.graphics.RectF()
|
|
||||||
|
|
||||||
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val annotMap = allAnnots.associateBy { it.name }
|
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||||
|
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||||
|
|
||||||
allAnnots.forEach { annot ->
|
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||||
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
val pdfRectF = if (pdfRectArray != null) {
|
||||||
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
android.graphics.RectF(
|
||||||
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
min(pdfRectArray[0], pdfRectArray[2]),
|
||||||
} else {
|
max(pdfRectArray[1], pdfRectArray[3]),
|
||||||
orphans.add(annot)
|
max(pdfRectArray[0], pdfRectArray[2]),
|
||||||
}
|
min(pdfRectArray[1], pdfRectArray[3])
|
||||||
}
|
|
||||||
|
|
||||||
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
|
||||||
|
|
||||||
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
|
|
||||||
orphans.forEach { annot ->
|
|
||||||
val match = groupedRoots.find { group ->
|
|
||||||
val root = group.first()
|
|
||||||
val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
|
|
||||||
android.graphics.RectF.intersects(inflatedRoot, annot.rect)
|
|
||||||
}
|
|
||||||
if (match != null) {
|
|
||||||
Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
|
|
||||||
match.add(annot)
|
|
||||||
} else {
|
|
||||||
groupedRoots.add(mutableListOf(annot))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val rootsWithReplies = groupedRoots.map { group ->
|
|
||||||
val root = group.first()
|
|
||||||
if (group.size > 1) {
|
|
||||||
root.replies.addAll(group.drop(1))
|
|
||||||
}
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
finalDisplayList = rootsWithReplies.filter {
|
|
||||||
!it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
|
|
||||||
}
|
|
||||||
|
|
||||||
mappedAnnots = finalDisplayList.map { annot ->
|
|
||||||
val screenRect = pageWrapper.mapRectToDevice(
|
|
||||||
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
|
||||||
currentPageRotation, annot.rect
|
|
||||||
)
|
)
|
||||||
annot to screenRect
|
} else android.graphics.RectF()
|
||||||
|
|
||||||
|
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val annotMap = allAnnots.associateBy { it.name }
|
||||||
|
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||||
|
|
||||||
|
allAnnots.forEach { annot ->
|
||||||
|
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
||||||
|
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
||||||
|
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
||||||
|
} else {
|
||||||
|
orphans.add(annot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
||||||
|
|
||||||
|
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
|
||||||
|
orphans.forEach { annot ->
|
||||||
|
val match = groupedRoots.find { group ->
|
||||||
|
val root = group.first()
|
||||||
|
val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
|
||||||
|
android.graphics.RectF.intersects(inflatedRoot, annot.rect)
|
||||||
|
}
|
||||||
|
if (match != null) {
|
||||||
|
Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
|
||||||
|
match.add(annot)
|
||||||
|
} else {
|
||||||
|
groupedRoots.add(mutableListOf(annot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rootsWithReplies = groupedRoots.map { group ->
|
||||||
|
val root = group.first()
|
||||||
|
if (group.size > 1) {
|
||||||
|
root.replies.addAll(group.drop(1))
|
||||||
|
}
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
finalDisplayList = rootsWithReplies.filter {
|
||||||
|
!it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedAnnots = finalDisplayList.map { annot ->
|
||||||
|
val screenRect = pageWrapper.mapRectToDevice(
|
||||||
|
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
||||||
|
currentPageRotation, annot.rect
|
||||||
|
)
|
||||||
|
annot to screenRect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
|
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
|
||||||
|
|
@ -1323,7 +1421,7 @@ internal fun PdfPageComposable(
|
||||||
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
|
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
|
||||||
val old = bitmapState
|
val old = bitmapState
|
||||||
bitmapState = null
|
bitmapState = null
|
||||||
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { }
|
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(targetPageId)) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1584,10 +1682,10 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchFocusedRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
var searchFocusedRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||||
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
var searchAllRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||||
|
|
||||||
var keyboardAdjustmentOriginalOffset by remember { mutableStateOf<Float?>(null) }
|
var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf<Float?>(null) }
|
||||||
|
|
||||||
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
|
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
|
||||||
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
|
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
|
||||||
|
|
@ -1932,10 +2030,6 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorSelection = stringResource(R.string.error_selection)
|
|
||||||
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
|
|
||||||
val errorProcessingPage = stringResource(R.string.error_processing_page)
|
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.onGloballyPositioned { layoutCoordinates = it }
|
.onGloballyPositioned { layoutCoordinates = it }
|
||||||
|
|
@ -2706,7 +2800,10 @@ internal fun PdfPageComposable(
|
||||||
Timber.e(
|
Timber.e(
|
||||||
e, "Long press: Error during OCR text selection"
|
e, "Long press: Error during OCR text selection"
|
||||||
)
|
)
|
||||||
pageErrorMessage = errorOcrSelection
|
pageErrorMessage = context.getString(
|
||||||
|
R.string.error_ocr_selection,
|
||||||
|
e.readablePdfErrorDetail()
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
isPerformingOcrForSelection = false
|
isPerformingOcrForSelection = false
|
||||||
ocrRipplePosition = null
|
ocrRipplePosition = null
|
||||||
|
|
@ -2727,7 +2824,10 @@ internal fun PdfPageComposable(
|
||||||
e,
|
e,
|
||||||
"Error during long press text selection on page $pageIndex"
|
"Error during long press text selection on page $pageIndex"
|
||||||
)
|
)
|
||||||
pageErrorMessage = errorSelection
|
pageErrorMessage = context.getString(
|
||||||
|
R.string.error_selection,
|
||||||
|
e.readablePdfErrorDetail()
|
||||||
|
)
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
selectionCharRange.value = null
|
selectionCharRange.value = null
|
||||||
selectedWordScreenRects = emptyList()
|
selectedWordScreenRects = emptyList()
|
||||||
|
|
@ -2774,7 +2874,8 @@ internal fun PdfPageComposable(
|
||||||
selectedTool,
|
selectedTool,
|
||||||
isStylusOnlyMode,
|
isStylusOnlyMode,
|
||||||
userHighlightScreenRects,
|
userHighlightScreenRects,
|
||||||
bubbleTapSlopPx
|
bubbleTapSlopPx,
|
||||||
|
isScrollLocked
|
||||||
) {
|
) {
|
||||||
val isTapDetectionAllowed = !isEditMode ||
|
val isTapDetectionAllowed = !isEditMode ||
|
||||||
selectedTool == InkType.TEXT ||
|
selectedTool == InkType.TEXT ||
|
||||||
|
|
@ -3040,7 +3141,7 @@ internal fun PdfPageComposable(
|
||||||
onScaleChanged(scale)
|
onScaleChanged(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isVerticalScroll && currentOnDoubleTap != null) {
|
} else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
|
||||||
currentOnDoubleTap!!(tapOffset)
|
currentOnDoubleTap!!(tapOffset)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -3276,7 +3377,7 @@ internal fun PdfPageComposable(
|
||||||
val startOffset = offset
|
val startOffset = offset
|
||||||
paginationPanFlingJob = coroutineScope.launch {
|
paginationPanFlingJob = coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
coroutineScope {
|
kotlinx.coroutines.coroutineScope {
|
||||||
launch {
|
launch {
|
||||||
if (flingX != 0f) {
|
if (flingX != 0f) {
|
||||||
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
||||||
|
|
@ -3543,15 +3644,37 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previousLockedViewportSize by remember { mutableStateOf<Pair<Dp, Dp>?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
||||||
isScrollLocked, lockedState
|
isScrollLocked, lockedState
|
||||||
) {
|
) {
|
||||||
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
|
val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight
|
||||||
scale = lockedState.first
|
val previousViewportSize = previousLockedViewportSize
|
||||||
offset = Offset(lockedState.second, lockedState.third)
|
val orientationChanged = previousViewportSize != null &&
|
||||||
|
(previousViewportSize.first > previousViewportSize.second) !=
|
||||||
|
(currentViewportSize.first > currentViewportSize.second)
|
||||||
|
previousLockedViewportSize = currentViewportSize
|
||||||
|
|
||||||
|
if (isScrollLocked && !isVerticalScroll) {
|
||||||
|
if (orientationChanged) {
|
||||||
|
scale = 1f
|
||||||
|
offset = Offset.Zero
|
||||||
|
hasAppliedLockedPaginationState = true
|
||||||
|
Timber.tag("PdfLockDiagnostic").i(
|
||||||
|
"Orientation changed while locked; reset paginated zoom to fit on page $pageIndex"
|
||||||
|
)
|
||||||
|
} else if (lockedState != null) {
|
||||||
|
scale = lockedState.first
|
||||||
|
offset = Offset(lockedState.second, lockedState.third)
|
||||||
|
hasAppliedLockedPaginationState = true
|
||||||
|
} else {
|
||||||
|
hasAppliedLockedPaginationState = true
|
||||||
|
}
|
||||||
onScaleChanged(scale)
|
onScaleChanged(scale)
|
||||||
} else if (!isScrollLocked && !isVerticalScroll) {
|
} else if (!isScrollLocked && !isVerticalScroll) {
|
||||||
|
hasAppliedLockedPaginationState = false
|
||||||
scale = 1f
|
scale = 1f
|
||||||
offset = Offset.Zero
|
offset = Offset.Zero
|
||||||
onScaleChanged(1f)
|
onScaleChanged(1f)
|
||||||
|
|
@ -3699,10 +3822,12 @@ internal fun PdfPageComposable(
|
||||||
currentContainerMaxHeight,
|
currentContainerMaxHeight,
|
||||||
density,
|
density,
|
||||||
virtualPage,
|
virtualPage,
|
||||||
|
targetPageId,
|
||||||
isVisible,
|
isVisible,
|
||||||
currentRenderedPageId
|
currentRenderedPageId
|
||||||
) {
|
) {
|
||||||
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
|
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
|
||||||
|
pageErrorMessage = null
|
||||||
|
|
||||||
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
|
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
|
||||||
val viewContainerHeightPx =
|
val viewContainerHeightPx =
|
||||||
|
|
@ -3721,12 +3846,18 @@ internal fun PdfPageComposable(
|
||||||
1f / 1.414f
|
1f / 1.414f
|
||||||
}
|
}
|
||||||
|
|
||||||
var scaledWidth = viewContainerWidthPx
|
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||||
var scaledHeight = (scaledWidth / pageAspect).toInt()
|
viewContainerWidthPx to viewContainerHeightPx
|
||||||
|
} else {
|
||||||
|
var fittedWidth = viewContainerWidthPx
|
||||||
|
var fittedHeight = (fittedWidth / pageAspect).toInt()
|
||||||
|
|
||||||
if (scaledHeight > viewContainerHeightPx) {
|
if (fittedHeight > viewContainerHeightPx) {
|
||||||
scaledHeight = viewContainerHeightPx
|
fittedHeight = viewContainerHeightPx
|
||||||
scaledWidth = (scaledHeight * pageAspect).toInt()
|
fittedWidth = (fittedHeight * pageAspect).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fittedWidth to fittedHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scaledWidth == actualBitmapWidthPx &&
|
if (scaledWidth == actualBitmapWidthPx &&
|
||||||
|
|
@ -3760,7 +3891,7 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
val old = bitmapState
|
val old = bitmapState
|
||||||
if (old != null && old !== finalBitmap) {
|
if (old != null && old !== finalBitmap) {
|
||||||
if (old !== PdfThumbnailCache.get(pageIndex)) {
|
if (old !== PdfThumbnailCache.get(targetPageId)) {
|
||||||
old.recycle()
|
old.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3771,7 +3902,7 @@ internal fun PdfPageComposable(
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
coroutineScope.launch {
|
kotlinx.coroutines.coroutineScope {
|
||||||
var localBitmap: Bitmap? = null
|
var localBitmap: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
val renderResult = withContext(Dispatchers.IO) {
|
val renderResult = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -3783,62 +3914,68 @@ internal fun PdfPageComposable(
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
||||||
val rotation = page.getPageRotation()
|
try {
|
||||||
val screenDpi = (density.density * 160).roundToInt()
|
val rotation = page.getPageRotation()
|
||||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||||
|
|
||||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||||
|
throw Exception("Invalid page dimensions")
|
||||||
|
}
|
||||||
|
|
||||||
|
val aspectRatio =
|
||||||
|
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
||||||
|
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||||
|
viewContainerWidthPx to viewContainerHeightPx
|
||||||
|
} else {
|
||||||
|
var fittedWidth = viewContainerWidthPx
|
||||||
|
var fittedHeight = (fittedWidth / aspectRatio).toInt()
|
||||||
|
|
||||||
|
if (fittedHeight > viewContainerHeightPx) {
|
||||||
|
fittedHeight = viewContainerHeightPx
|
||||||
|
fittedWidth = (fittedHeight * aspectRatio).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fittedWidth to fittedHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scaledWidth == actualBitmapWidthPx &&
|
||||||
|
scaledHeight == actualBitmapHeightPx &&
|
||||||
|
bitmapState != null &&
|
||||||
|
currentRenderedPageId == targetPageId
|
||||||
|
) {
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val MAX_BASE_DIMEN = 3000
|
||||||
|
|
||||||
|
val baseRenderScale = 1.5f
|
||||||
|
|
||||||
|
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||||
|
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||||
|
|
||||||
|
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||||
|
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||||
|
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||||
|
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d(
|
||||||
|
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
||||||
|
)
|
||||||
|
val newBitmap = createBitmap(baseW, baseH)
|
||||||
|
localBitmap = newBitmap
|
||||||
|
page.renderPageBitmap(
|
||||||
|
newBitmap,
|
||||||
|
0, 0,
|
||||||
|
baseW, baseH,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
||||||
|
} finally {
|
||||||
page.close()
|
page.close()
|
||||||
throw Exception("Invalid page dimensions")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val aspectRatio =
|
|
||||||
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
|
||||||
var scaledWidth = viewContainerWidthPx
|
|
||||||
var scaledHeight = (scaledWidth / aspectRatio).toInt()
|
|
||||||
|
|
||||||
if (scaledHeight > viewContainerHeightPx) {
|
|
||||||
scaledHeight = viewContainerHeightPx
|
|
||||||
scaledWidth = (scaledHeight * aspectRatio).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scaledWidth == actualBitmapWidthPx &&
|
|
||||||
scaledHeight == actualBitmapHeightPx &&
|
|
||||||
bitmapState != null &&
|
|
||||||
currentRenderedPageId == targetPageId
|
|
||||||
) {
|
|
||||||
page.close()
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
|
|
||||||
val MAX_BASE_DIMEN = 3000
|
|
||||||
|
|
||||||
val baseRenderScale = 1.5f
|
|
||||||
|
|
||||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
|
||||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
|
||||||
|
|
||||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
|
||||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
|
||||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
|
||||||
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d(
|
|
||||||
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
|
||||||
)
|
|
||||||
val newBitmap = createBitmap(baseW, baseH)
|
|
||||||
localBitmap = newBitmap
|
|
||||||
page.renderPageBitmap(
|
|
||||||
newBitmap,
|
|
||||||
0, 0,
|
|
||||||
baseW, baseH,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
page.close()
|
|
||||||
|
|
||||||
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (renderResult != null) {
|
if (renderResult != null) {
|
||||||
|
|
@ -3856,7 +3993,7 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (old != null && old !== newBitmap && !old.isRecycled) {
|
if (old != null && old !== newBitmap && !old.isRecycled) {
|
||||||
val cached = PdfThumbnailCache.get(pageIndex)
|
val cached = PdfThumbnailCache.get(targetPageId)
|
||||||
if (old !== cached) {
|
if (old !== cached) {
|
||||||
old.recycle()
|
old.recycle()
|
||||||
}
|
}
|
||||||
|
|
@ -3866,14 +4003,17 @@ internal fun PdfPageComposable(
|
||||||
val thumbHeight = newBitmap.height / 2
|
val thumbHeight = newBitmap.height / 2
|
||||||
if (thumbWidth > 0 && thumbHeight > 0) {
|
if (thumbWidth > 0 && thumbHeight > 0) {
|
||||||
PdfThumbnailCache.put(
|
PdfThumbnailCache.put(
|
||||||
pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
|
targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
pageErrorMessage = errorProcessingPage
|
pageErrorMessage = context.getString(
|
||||||
|
R.string.error_processing_page,
|
||||||
|
e.readablePdfErrorDetail()
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingPage = false
|
isLoadingPage = false
|
||||||
localBitmap?.recycle()
|
localBitmap?.recycle()
|
||||||
|
|
@ -4265,6 +4405,7 @@ internal fun PdfPageComposable(
|
||||||
contentToScreenCoordinates = contentToScreenCoordinates,
|
contentToScreenCoordinates = contentToScreenCoordinates,
|
||||||
density = density,
|
density = density,
|
||||||
isVerticalScroll = isVerticalScroll,
|
isVerticalScroll = isVerticalScroll,
|
||||||
|
showPageNumberOverlay = showPageNumberOverlay,
|
||||||
isScrolling = isScrolling,
|
isScrolling = isScrolling,
|
||||||
isEditMode = isEditMode,
|
isEditMode = isEditMode,
|
||||||
selectedTool = selectedTool,
|
selectedTool = selectedTool,
|
||||||
|
|
@ -4296,7 +4437,7 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.error_unable_to_display_page),
|
text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
|
|
@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer(
|
||||||
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||||
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
||||||
if (bitmapState != null && !bitmapState.isRecycled) {
|
if (
|
||||||
|
bitmapState != null &&
|
||||||
|
bitmapState.isCanvasSafeBitmap(
|
||||||
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||||
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||||
|
)
|
||||||
|
) {
|
||||||
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
|
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
|
||||||
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
|
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
|
||||||
val srcSize = IntSize(bitmapState.width, bitmapState.height)
|
val srcSize = IntSize(bitmapState.width, bitmapState.height)
|
||||||
|
|
@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer(
|
||||||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||||
if (needsTiling) {
|
if (needsTiling) {
|
||||||
tiles.forEach { tile ->
|
tiles.forEach { tile ->
|
||||||
if (!tile.bitmap.isRecycled) {
|
if (
|
||||||
|
tile.bitmap.isCanvasSafeBitmap(
|
||||||
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||||
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||||
|
)
|
||||||
|
) {
|
||||||
drawImage(
|
drawImage(
|
||||||
image = tile.bitmap.asImageBitmap(),
|
image = tile.bitmap.asImageBitmap(),
|
||||||
srcOffset = IntOffset.Zero,
|
srcOffset = IntOffset.Zero,
|
||||||
|
|
@ -5086,6 +5238,7 @@ private fun PdfPageRenderer(
|
||||||
contentToScreenCoordinates: (Offset) -> Offset,
|
contentToScreenCoordinates: (Offset) -> Offset,
|
||||||
density: Density,
|
density: Density,
|
||||||
isVerticalScroll: Boolean,
|
isVerticalScroll: Boolean,
|
||||||
|
showPageNumberOverlay: Boolean,
|
||||||
isScrolling: Boolean,
|
isScrolling: Boolean,
|
||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
selectedTool: InkType,
|
selectedTool: InkType,
|
||||||
|
|
@ -5282,7 +5435,7 @@ private fun PdfPageRenderer(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 4: Page Number Indicator
|
// Layer 4: Page Number Indicator
|
||||||
if (totalPages > 0) {
|
if (showPageNumberOverlay && totalPages > 0) {
|
||||||
val pageNumColor = if (staticData.isDarkMode) {
|
val pageNumColor = if (staticData.isDarkMode) {
|
||||||
Color.White
|
Color.White
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -5439,10 +5592,12 @@ private fun PdfPageRenderer(
|
||||||
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
||||||
currentScale = effectiveScale,
|
currentScale = effectiveScale,
|
||||||
magnifierCenterOnBitmap = magnifierCenterTarget,
|
magnifierCenterOnBitmap = magnifierCenterTarget,
|
||||||
|
contentWidthPx = staticData.targetWidth,
|
||||||
|
contentHeightPx = staticData.targetHeight,
|
||||||
magnifierWidth = magnifierWidth,
|
magnifierWidth = magnifierWidth,
|
||||||
magnifierHeight = magnifierHeight,
|
magnifierHeight = magnifierHeight,
|
||||||
zoomFactor = effectiveZoomFactor,
|
zoomFactor = effectiveZoomFactor,
|
||||||
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
|
selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
|
||||||
highlightColor = Color(0x6633B5E5),
|
highlightColor = Color(0x6633B5E5),
|
||||||
colorFilter = staticData.colorFilter.item,
|
colorFilter = staticData.colorFilter.item,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -5593,6 +5748,21 @@ private fun PdfPageRenderer(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
||||||
|
val baseBitmap = staticData.bitmap.item ?: return@Canvas
|
||||||
|
if (
|
||||||
|
!baseBitmap.isCanvasSafeBitmap(
|
||||||
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||||
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return@Canvas
|
||||||
|
}
|
||||||
|
val safeExpandedBubbleRender = expandedBubbleRender?.takeIf {
|
||||||
|
it.bitmap.isCanvasSafeBitmap(
|
||||||
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||||
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||||
|
)
|
||||||
|
}
|
||||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||||
|
|
@ -5600,7 +5770,7 @@ private fun PdfPageRenderer(
|
||||||
val logicalHeight = bubble.bounds.height()
|
val logicalHeight = bubble.bounds.height()
|
||||||
val pivotX = left + logicalWidth / 2f
|
val pivotX = left + logicalWidth / 2f
|
||||||
val pivotY = top + logicalHeight / 2f
|
val pivotY = top + logicalHeight / 2f
|
||||||
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||||
bubbleBounds = bubble.bounds,
|
bubbleBounds = bubble.bounds,
|
||||||
viewportWidth = staticData.canvasWidth,
|
viewportWidth = staticData.canvasWidth,
|
||||||
viewportHeight = staticData.canvasHeight
|
viewportHeight = staticData.canvasHeight
|
||||||
|
|
@ -5613,8 +5783,8 @@ private fun PdfPageRenderer(
|
||||||
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
||||||
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
||||||
|
|
||||||
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
|
val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
|
||||||
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
|
val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
|
||||||
|
|
||||||
val srcOffset = IntOffset(
|
val srcOffset = IntOffset(
|
||||||
(bubble.bounds.left * renderScaleX).toInt(),
|
(bubble.bounds.left * renderScaleX).toInt(),
|
||||||
|
|
@ -5651,12 +5821,12 @@ private fun PdfPageRenderer(
|
||||||
)
|
)
|
||||||
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
||||||
drawImage(
|
drawImage(
|
||||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||||
srcSize = if (expandedBubbleRender != null) {
|
srcSize = if (safeExpandedBubbleRender != null) {
|
||||||
IntSize(
|
IntSize(
|
||||||
expandedBubbleRender.bitmap.width,
|
safeExpandedBubbleRender.bitmap.width,
|
||||||
expandedBubbleRender.bitmap.height)
|
safeExpandedBubbleRender.bitmap.height)
|
||||||
} else {
|
} else {
|
||||||
srcSize
|
srcSize
|
||||||
},
|
},
|
||||||
|
|
@ -5675,12 +5845,12 @@ private fun PdfPageRenderer(
|
||||||
} else {
|
} else {
|
||||||
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
||||||
drawImage(
|
drawImage(
|
||||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||||
srcSize = if (expandedBubbleRender != null) {
|
srcSize = if (safeExpandedBubbleRender != null) {
|
||||||
IntSize(
|
IntSize(
|
||||||
expandedBubbleRender.bitmap.width,
|
safeExpandedBubbleRender.bitmap.width,
|
||||||
expandedBubbleRender.bitmap.height)
|
safeExpandedBubbleRender.bitmap.height)
|
||||||
} else {
|
} else {
|
||||||
srcSize
|
srcSize
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
||||||
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
||||||
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
||||||
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||||
|
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
|
||||||
|
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
|
||||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||||
|
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||||
|
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||||
|
|
||||||
enum class PdfReaderTool(val title: String, val category: String) {
|
enum class PdfReaderTool(val title: String, val category: String) {
|
||||||
DICTIONARY("External Apps", "Top Bar"),
|
DICTIONARY("External Apps", "Top Bar"),
|
||||||
|
|
@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) {
|
||||||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
|
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
||||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||||
|
|
@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf(
|
||||||
|
|
||||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||||
|
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||||
|
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||||
|
val migratedHiddenTools = savedHiddenTools + setOf(
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||||
|
)
|
||||||
|
prefs.edit {
|
||||||
|
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||||
|
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
|
}
|
||||||
|
return migratedHiddenTools
|
||||||
|
}
|
||||||
|
return savedHiddenTools
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
prefs.edit {
|
||||||
|
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools)
|
||||||
|
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
||||||
|
|
@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
|
||||||
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun savePdfVerticalPageGapVisible(context: Context, isVisible: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, isVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfVerticalPageGapVisible(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfPageNumberOverlayVisible(context: Context, isVisible: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, isVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.itemContentType
|
import androidx.paging.compose.itemContentType
|
||||||
import androidx.paging.compose.itemKey
|
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
|
|
||||||
|
|
@ -154,9 +153,10 @@ fun PdfSearchResultsPanel(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||||
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
|
items(
|
||||||
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
|
count = lazyResults.itemCount,
|
||||||
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
|
contentType = lazyResults.itemContentType { "SearchResult" }
|
||||||
|
) { index ->
|
||||||
val result = lazyResults[index]
|
val result = lazyResults[index]
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ScreenRotation
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet(
|
||||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
|
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION
|
||||||
)
|
)
|
||||||
|
|
||||||
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||||
|
|
@ -504,6 +508,7 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
||||||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
||||||
@Composable
|
@Composable
|
||||||
fun PdfVisualOptionsSheet(
|
fun PdfVisualOptionsSheet(
|
||||||
systemUiMode: SystemUiMode,
|
systemUiMode: SystemUiMode,
|
||||||
|
showVerticalPageGap: Boolean,
|
||||||
|
showPageNumberOverlay: Boolean,
|
||||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||||
|
onShowVerticalPageGapChange: (Boolean) -> Unit,
|
||||||
|
onShowPageNumberOverlayChange: (Boolean) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet(
|
||||||
onOptionSelected = onSystemUiModeChange,
|
onOptionSelected = onSystemUiModeChange,
|
||||||
getLabel = { it.title }
|
getLabel = { it.title }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
PdfVisualOptionSwitchRow(
|
||||||
|
title = stringResource(R.string.visual_options_remove_page_gap),
|
||||||
|
description = stringResource(R.string.visual_options_remove_page_gap_desc),
|
||||||
|
checked = !showVerticalPageGap,
|
||||||
|
onCheckedChange = { removeGap ->
|
||||||
|
onShowVerticalPageGapChange(!removeGap)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PdfVisualOptionSwitchRow(
|
||||||
|
title = stringResource(R.string.visual_options_hide_page_number_overlay),
|
||||||
|
description = stringResource(R.string.visual_options_hide_page_number_overlay_desc),
|
||||||
|
checked = !showPageNumberOverlay,
|
||||||
|
onCheckedChange = { hideOverlay ->
|
||||||
|
onShowPageNumberOverlayChange(!hideOverlay)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfVisualOptionSwitchRow(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
import com.aryan.reader.epubreader.SystemUiMode
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
import kotlin.collections.isNotEmpty
|
import kotlin.collections.isNotEmpty
|
||||||
|
|
||||||
|
internal val PdfTabStripHeight = 44.dp
|
||||||
|
|
||||||
private val pdfToolbarTools = setOf(
|
private val pdfToolbarTools = setOf(
|
||||||
PdfReaderTool.DICTIONARY,
|
PdfReaderTool.DICTIONARY,
|
||||||
PdfReaderTool.THEME,
|
PdfReaderTool.THEME,
|
||||||
|
|
@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf(
|
||||||
PdfReaderTool.HIGHLIGHT_ALL,
|
PdfReaderTool.HIGHLIGHT_ALL,
|
||||||
PdfReaderTool.AI_FEATURES,
|
PdfReaderTool.AI_FEATURES,
|
||||||
PdfReaderTool.EDIT_MODE,
|
PdfReaderTool.EDIT_MODE,
|
||||||
PdfReaderTool.TTS_CONTROLS
|
PdfReaderTool.TTS_CONTROLS,
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -81,6 +84,7 @@ internal fun PdfTopBar(
|
||||||
isScrollLocked: Boolean,
|
isScrollLocked: Boolean,
|
||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
displayMode: DisplayMode,
|
displayMode: DisplayMode,
|
||||||
|
isRightToLeftPagination: Boolean,
|
||||||
isKeepScreenOn: Boolean,
|
isKeepScreenOn: Boolean,
|
||||||
isTtsSessionActive: Boolean,
|
isTtsSessionActive: Boolean,
|
||||||
isBookmarked: Boolean,
|
isBookmarked: Boolean,
|
||||||
|
|
@ -101,6 +105,7 @@ internal fun PdfTopBar(
|
||||||
onShowCustomizeTools: () -> Unit,
|
onShowCustomizeTools: () -> Unit,
|
||||||
onShowOcrLanguage: () -> Unit,
|
onShowOcrLanguage: () -> Unit,
|
||||||
onShowVisualOptions: () -> Unit,
|
onShowVisualOptions: () -> Unit,
|
||||||
|
onShowScreenOrientation: () -> Unit,
|
||||||
onShowSlider: () -> Unit,
|
onShowSlider: () -> Unit,
|
||||||
onShowToc: () -> Unit,
|
onShowToc: () -> Unit,
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
|
|
@ -114,6 +119,7 @@ internal fun PdfTopBar(
|
||||||
tapToNavigateEnabled: Boolean,
|
tapToNavigateEnabled: Boolean,
|
||||||
onToggleTapToNavigate: () -> Unit,
|
onToggleTapToNavigate: () -> Unit,
|
||||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||||
|
onSetRightToLeftPagination: (Boolean) -> Unit,
|
||||||
onToggleKeepScreenOn: () -> Unit,
|
onToggleKeepScreenOn: () -> Unit,
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
onShowTtsSettings: () -> Unit,
|
onShowTtsSettings: () -> Unit,
|
||||||
|
|
@ -262,6 +268,13 @@ internal fun PdfTopBar(
|
||||||
) {
|
) {
|
||||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.menu_screen_orientation),
|
||||||
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
onClick = onShowScreenOrientation
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -281,11 +294,17 @@ internal fun PdfTopBar(
|
||||||
Box {
|
Box {
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showFileActionsExpanded by remember { mutableStateOf(false) }
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = stringResource(R.string.tooltip_more_options),
|
text = stringResource(R.string.tooltip_more_options),
|
||||||
description = stringResource(R.string.tooltip_more_options_desc),
|
description = stringResource(R.string.tooltip_more_options_desc),
|
||||||
onClick = {
|
onClick = {
|
||||||
showHiddenToolsExpanded = false
|
showHiddenToolsExpanded = false
|
||||||
|
showReadingModeExpanded = false
|
||||||
|
showTtsSettingsExpanded = false
|
||||||
|
showFileActionsExpanded = false
|
||||||
showMoreMenu = true
|
showMoreMenu = true
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||||
|
|
@ -295,6 +314,9 @@ internal fun PdfTopBar(
|
||||||
expanded = showMoreMenu,
|
expanded = showMoreMenu,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showHiddenToolsExpanded = false
|
showHiddenToolsExpanded = false
|
||||||
|
showReadingModeExpanded = false
|
||||||
|
showTtsSettingsExpanded = false
|
||||||
|
showFileActionsExpanded = false
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -340,7 +362,8 @@ internal fun PdfTopBar(
|
||||||
onToggleHighlights = onToggleHighlights,
|
onToggleHighlights = onToggleHighlights,
|
||||||
onShowAiHub = onShowAiHub,
|
onShowAiHub = onShowAiHub,
|
||||||
onToggleEditMode = onToggleEditMode,
|
onToggleEditMode = onToggleEditMode,
|
||||||
onToggleTts = onToggleTts
|
onToggleTts = onToggleTts,
|
||||||
|
onShowScreenOrientation = onShowScreenOrientation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,18 +389,52 @@ internal fun PdfTopBar(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||||
enabled = !isTtsSessionActive,
|
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
trailingIcon = {
|
||||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
Icon(
|
||||||
)
|
Icons.Default.ArrowDropDown,
|
||||||
HorizontalDivider()
|
contentDescription = null,
|
||||||
DropdownMenuItem(
|
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
)
|
||||||
enabled = !isTtsSessionActive,
|
}
|
||||||
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
|
|
||||||
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
|
||||||
)
|
)
|
||||||
|
if (showReadingModeExpanded) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||||
|
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = {
|
||||||
|
onSetRightToLeftPagination(false)
|
||||||
|
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = {
|
||||||
|
onSetRightToLeftPagination(true)
|
||||||
|
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,24 +476,41 @@ internal fun PdfTopBar(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
|
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||||
|
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||||
|
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||||
enabled = !isTtsSessionActive,
|
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
if (showTtsSettingsExpanded) {
|
||||||
|
if (showTtsVoiceSettings) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTtsReplacements) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||||
|
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
|
||||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
|
||||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||||
|
|
@ -462,7 +536,7 @@ internal fun PdfTopBar(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||||
onClick = { showMoreMenu = false; onReflowAction() },
|
onClick = { showMoreMenu = false; onReflowAction() },
|
||||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
|
@ -470,28 +544,45 @@ internal fun PdfTopBar(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
|
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||||
|
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||||
|
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||||
|
if (showShareAction || showSaveCopyAction || showPrintAction) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.action_share)) },
|
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||||
onClick = { showMoreMenu = false; onShare() },
|
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
)
|
trailingIcon = {
|
||||||
}
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
|
contentDescription = null,
|
||||||
DropdownMenuItem(
|
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
)
|
||||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
}
|
||||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.action_print)) },
|
|
||||||
onClick = { showMoreMenu = false; onPrint() },
|
|
||||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
|
||||||
)
|
)
|
||||||
|
if (showFileActionsExpanded) {
|
||||||
|
if (showShareAction) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_share)) },
|
||||||
|
onClick = { showMoreMenu = false; onShare() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showSaveCopyAction) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||||
|
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPrintAction) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_print)) },
|
||||||
|
onClick = { showMoreMenu = false; onPrint() },
|
||||||
|
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -499,7 +590,7 @@ internal fun PdfTopBar(
|
||||||
}
|
}
|
||||||
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
modifier = Modifier.fillMaxWidth().height(PdfTabStripHeight).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
items(openTabs, key = { it.bookId }) { tab ->
|
items(openTabs, key = { it.bookId }) { tab ->
|
||||||
|
|
@ -509,7 +600,7 @@ internal fun PdfTopBar(
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(if (isSelected) 44.dp else 36.dp)
|
.height(if (isSelected) PdfTabStripHeight else 36.dp)
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.clickable { onTabClick(tab.bookId) }
|
.clickable { onTabClick(tab.bookId) }
|
||||||
|
|
@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem(
|
||||||
onToggleHighlights: () -> Unit,
|
onToggleHighlights: () -> Unit,
|
||||||
onShowAiHub: () -> Unit,
|
onShowAiHub: () -> Unit,
|
||||||
onToggleEditMode: () -> Unit,
|
onToggleEditMode: () -> Unit,
|
||||||
onToggleTts: () -> Unit
|
onToggleTts: () -> Unit,
|
||||||
|
onShowScreenOrientation: () -> Unit
|
||||||
) {
|
) {
|
||||||
val enabled = when (tool) {
|
val enabled = when (tool) {
|
||||||
PdfReaderTool.SLIDER,
|
PdfReaderTool.SLIDER,
|
||||||
|
|
@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem(
|
||||||
PdfReaderTool.AI_FEATURES -> onShowAiHub()
|
PdfReaderTool.AI_FEATURES -> onShowAiHub()
|
||||||
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
|
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
|
||||||
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
|
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation()
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -606,6 +699,7 @@ private fun HiddenPdfToolMenuItem(
|
||||||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
|
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -766,6 +860,8 @@ fun PdfBottomBar(
|
||||||
onShowAiHub: () -> Unit,
|
onShowAiHub: () -> Unit,
|
||||||
onToggleEditMode: () -> Unit,
|
onToggleEditMode: () -> Unit,
|
||||||
onToggleTts: () -> Unit,
|
onToggleTts: () -> Unit,
|
||||||
|
onShowScreenOrientation: () -> Unit,
|
||||||
|
showBubbleZoom: Boolean,
|
||||||
isBubbleZoomModeActive: Boolean,
|
isBubbleZoomModeActive: Boolean,
|
||||||
onToggleBubbleZoom: () -> Unit
|
onToggleBubbleZoom: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -868,11 +964,18 @@ fun PdfBottomBar(
|
||||||
) {
|
) {
|
||||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.menu_screen_orientation),
|
||||||
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||||
|
onClick = onShowScreenOrientation
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.FLAVOR != "oss") {
|
if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) {
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom),
|
text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom),
|
||||||
description = stringResource(R.string.desc_toggle_smart_comic_zoom),
|
description = stringResource(R.string.desc_toggle_smart_comic_zoom),
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
|
@ -101,7 +99,6 @@ import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.layoutId
|
import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInWindow
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -116,6 +113,8 @@ import com.aryan.reader.ml.SpeechBubble
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||||
import com.aryan.reader.pdf.data.PdfTextBox
|
import com.aryan.reader.pdf.data.PdfTextBox
|
||||||
import com.aryan.reader.pdf.data.VirtualPage
|
import com.aryan.reader.pdf.data.VirtualPage
|
||||||
|
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
|
||||||
|
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
|
||||||
|
|
||||||
private data class PdfPageLayout(
|
private data class PdfPageLayout(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val y: Float,
|
val yPx: Int,
|
||||||
val height: Float,
|
val heightPx: Int,
|
||||||
val width: Float,
|
val widthPx: Int,
|
||||||
val widthDp: Dp,
|
val widthDp: Dp,
|
||||||
val heightDp: Dp
|
val heightDp: Dp
|
||||||
|
) {
|
||||||
|
val y: Float
|
||||||
|
get() = yPx.toFloat()
|
||||||
|
|
||||||
|
val height: Float
|
||||||
|
get() = heightPx.toFloat()
|
||||||
|
|
||||||
|
val width: Float
|
||||||
|
get() = widthPx.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) {
|
||||||
|
val y: Float
|
||||||
|
get() = yPx.toFloat()
|
||||||
|
|
||||||
|
val width: Float
|
||||||
|
get() = widthPx.toFloat()
|
||||||
|
|
||||||
|
val height: Float
|
||||||
|
get() = heightPx.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class PdfLockedOrientationResetCamera(
|
||||||
|
val zoom: Float,
|
||||||
|
val panX: Float,
|
||||||
|
val panY: Float
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DividerLayout(val y: Float, val width: Float, val height: Float)
|
internal fun calculateLockedOrientationResetCamera(
|
||||||
|
pageTopY: Float,
|
||||||
|
totalDocHeight: Float,
|
||||||
|
screenWidth: Float,
|
||||||
|
screenHeight: Float,
|
||||||
|
headerHeightPx: Float,
|
||||||
|
footerHeightPx: Float,
|
||||||
|
fitZoom: Float
|
||||||
|
): PdfLockedOrientationResetCamera {
|
||||||
|
val targetPanY = headerHeightPx - (pageTopY * fitZoom)
|
||||||
|
val zoomedDocHeight = totalDocHeight * fitZoom
|
||||||
|
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
|
||||||
|
headerHeightPx
|
||||||
|
} else {
|
||||||
|
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||||
|
}
|
||||||
|
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
|
||||||
|
|
||||||
|
val zoomedDocWidth = screenWidth * fitZoom
|
||||||
|
val targetPanX = if (zoomedDocWidth < screenWidth) {
|
||||||
|
(screenWidth - zoomedDocWidth) / 2f
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
return PdfLockedOrientationResetCamera(
|
||||||
|
zoom = fitZoom,
|
||||||
|
panX = targetPanX,
|
||||||
|
panY = finalPanY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UnusedVariable")
|
@Suppress("UnusedVariable")
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
|
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
|
||||||
|
|
@ -192,6 +247,7 @@ internal fun PdfVerticalReader(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
state: VerticalPdfReaderState,
|
state: VerticalPdfReaderState,
|
||||||
pdfDocument: StableHolder<ReaderDocument>,
|
pdfDocument: StableHolder<ReaderDocument>,
|
||||||
|
documentKey: String,
|
||||||
activeTheme: com.aryan.reader.ReaderTheme,
|
activeTheme: com.aryan.reader.ReaderTheme,
|
||||||
activeTextureAlpha: Float = 0.55f,
|
activeTextureAlpha: Float = 0.55f,
|
||||||
excludeImages: Boolean = false,
|
excludeImages: Boolean = false,
|
||||||
|
|
@ -230,6 +286,7 @@ internal fun PdfVerticalReader(
|
||||||
selectedTool: InkType,
|
selectedTool: InkType,
|
||||||
richTextController: RichTextController? = null,
|
richTextController: RichTextController? = null,
|
||||||
textBoxes: List<PdfTextBox> = emptyList(),
|
textBoxes: List<PdfTextBox> = emptyList(),
|
||||||
|
textBoxesByPage: Map<Int, List<PdfTextBox>> = emptyMap(),
|
||||||
selectedTextBoxId: String? = null,
|
selectedTextBoxId: String? = null,
|
||||||
onTextBoxChange: (PdfTextBox) -> Unit = {},
|
onTextBoxChange: (PdfTextBox) -> Unit = {},
|
||||||
onTextBoxSelect: (String) -> Unit = {},
|
onTextBoxSelect: (String) -> Unit = {},
|
||||||
|
|
@ -245,6 +302,7 @@ internal fun PdfVerticalReader(
|
||||||
stylusButtonHovering: Boolean = false,
|
stylusButtonHovering: Boolean = false,
|
||||||
isHighlighterSnapEnabled: Boolean = false,
|
isHighlighterSnapEnabled: Boolean = false,
|
||||||
userHighlights: List<PdfUserHighlight> = emptyList(),
|
userHighlights: List<PdfUserHighlight> = emptyList(),
|
||||||
|
userHighlightsByPage: Map<Int, List<PdfUserHighlight>> = emptyMap(),
|
||||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
||||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
||||||
onHighlightDelete: (String) -> Unit = {},
|
onHighlightDelete: (String) -> Unit = {},
|
||||||
|
|
@ -258,9 +316,10 @@ internal fun PdfVerticalReader(
|
||||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||||
resetZoomTrigger: Long = 0L,
|
resetZoomTrigger: Long = 0L,
|
||||||
isBubbleZoomModeActive: Boolean = false,
|
isBubbleZoomModeActive: Boolean = false,
|
||||||
|
showPageGap: Boolean = true,
|
||||||
|
showPageNumberOverlay: Boolean = true,
|
||||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
||||||
) {
|
) {
|
||||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
|
||||||
DisposableEffect(state) {
|
DisposableEffect(state) {
|
||||||
onDispose {
|
onDispose {
|
||||||
state.scrollToPageHandler = null
|
state.scrollToPageHandler = null
|
||||||
|
|
@ -273,6 +332,13 @@ internal fun PdfVerticalReader(
|
||||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||||
|
val verticalPageBackgroundColor = remember(activeTheme) {
|
||||||
|
when (activeTheme.id) {
|
||||||
|
"no_theme", "system" -> Color.White
|
||||||
|
"reverse" -> Color.Black
|
||||||
|
else -> activeTheme.backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||||
val imeInsets = WindowInsets.ime
|
val imeInsets = WindowInsets.ime
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -282,6 +348,12 @@ internal fun PdfVerticalReader(
|
||||||
|
|
||||||
val ratios = pageAspectRatios.item
|
val ratios = pageAspectRatios.item
|
||||||
val bookmarkSet = bookmarks.item
|
val bookmarkSet = bookmarks.item
|
||||||
|
val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) {
|
||||||
|
textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } }
|
||||||
|
}
|
||||||
|
val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) {
|
||||||
|
userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } }
|
||||||
|
}
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -294,56 +366,37 @@ internal fun PdfVerticalReader(
|
||||||
val headerHeightPx = with(density) { headerHeight.toPx() }
|
val headerHeightPx = with(density) { headerHeight.toPx() }
|
||||||
val footerHeightPx = with(density) { footerHeight.toPx() }
|
val footerHeightPx = with(density) { footerHeight.toPx() }
|
||||||
|
|
||||||
val dividerHeightDp = 8.dp
|
val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp)
|
||||||
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
||||||
|
val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0)
|
||||||
|
|
||||||
var isFlinging by remember { mutableStateOf(false) }
|
var isFlinging by remember { mutableStateOf(false) }
|
||||||
var isFastFlinging by remember { mutableStateOf(false) }
|
var isFastFlinging by remember { mutableStateOf(false) }
|
||||||
var isInteracting by remember { mutableStateOf(false) }
|
var isInteracting by remember { mutableStateOf(false) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
|
||||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||||
|
|
||||||
var currentY = 0.0
|
val verticalLayout = calculatePdfVerticalPageLayoutPx(
|
||||||
|
pageAspectRatios = ratios,
|
||||||
|
viewportWidthPx = constraints.maxWidth,
|
||||||
|
viewportHeightPx = constraints.maxHeight,
|
||||||
|
pageGapPx = dividerHeightPxInt
|
||||||
|
)
|
||||||
|
|
||||||
if (ratios.size == 1) {
|
val pages = verticalLayout.pages.map { page ->
|
||||||
val ratio = ratios[0]
|
PdfPageLayout(
|
||||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
index = page.pageIndex,
|
||||||
val pageHeight = screenWidth / safeRatio
|
yPx = page.topPx,
|
||||||
if (pageHeight < screenHeight) {
|
heightPx = page.heightPx,
|
||||||
currentY = ((screenHeight - pageHeight) / 2f).toDouble()
|
widthPx = page.widthPx,
|
||||||
}
|
widthDp = with(density) { page.widthPx.toDp() },
|
||||||
|
heightDp = with(density) { page.heightPx.toDp() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val pages = ratios.mapIndexed { index, ratio ->
|
LayoutResult(pages, verticalLayout.totalHeightPx.toFloat())
|
||||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
|
||||||
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
|
|
||||||
val pageHeight = pageHeightDouble.toFloat()
|
|
||||||
|
|
||||||
val info = PdfPageLayout(
|
|
||||||
index = index,
|
|
||||||
y = currentY.toFloat(),
|
|
||||||
height = pageHeight,
|
|
||||||
width = screenWidth,
|
|
||||||
widthDp = with(density) { screenWidth.toDp() },
|
|
||||||
heightDp = with(density) { pageHeight.toDp() })
|
|
||||||
|
|
||||||
currentY += pageHeightDouble
|
|
||||||
if (index < ratios.lastIndex) {
|
|
||||||
currentY += dividerHeightPx
|
|
||||||
}
|
|
||||||
info
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalH = if (pages.isNotEmpty()) {
|
|
||||||
val last = pages.last()
|
|
||||||
last.y + last.height
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
LayoutResult(pages, totalH)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val layoutInfo = layoutState.pages
|
val layoutInfo = layoutState.pages
|
||||||
|
|
@ -375,11 +428,17 @@ internal fun PdfVerticalReader(
|
||||||
var isResizing by remember { mutableStateOf(false) }
|
var isResizing by remember { mutableStateOf(false) }
|
||||||
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
||||||
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
||||||
|
var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) }
|
||||||
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
|
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
|
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
|
||||||
if (previousScreenWidth > 0f) {
|
if (previousScreenWidth > 0f) {
|
||||||
|
val previousWasLandscape = previousScreenWidth > previousScreenHeight
|
||||||
|
val currentIsLandscape = screenWidth > screenHeight
|
||||||
isResizing = true
|
isResizing = true
|
||||||
|
if (isScrollLocked && previousWasLandscape != currentIsLandscape) {
|
||||||
|
lockedOrientationChangedDuringResize = true
|
||||||
|
}
|
||||||
if (targetPageDuringResize.intValue == -1) {
|
if (targetPageDuringResize.intValue == -1) {
|
||||||
targetPageDuringResize.intValue = state.currentPage
|
targetPageDuringResize.intValue = state.currentPage
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +483,45 @@ internal fun PdfVerticalReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(layoutState.pages) {
|
LaunchedEffect(layoutState.pages) {
|
||||||
if (!isInitialLayout && !isScrollLocked) {
|
if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) {
|
||||||
|
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||||
|
targetPageDuringResize.intValue
|
||||||
|
} else {
|
||||||
|
state.currentPage
|
||||||
|
}
|
||||||
|
|
||||||
|
val newLayout = layoutState.pages
|
||||||
|
val pageLayout = newLayout.getOrNull(targetPageIdx)
|
||||||
|
|
||||||
|
if (pageLayout != null) {
|
||||||
|
val resetCamera = calculateLockedOrientationResetCamera(
|
||||||
|
pageTopY = pageLayout.y,
|
||||||
|
totalDocHeight = layoutState.totalHeight,
|
||||||
|
screenWidth = screenWidth,
|
||||||
|
screenHeight = screenHeight,
|
||||||
|
headerHeightPx = headerHeightPx,
|
||||||
|
footerHeightPx = footerHeightPx,
|
||||||
|
fitZoom = fitZoom
|
||||||
|
)
|
||||||
|
|
||||||
|
panXAnimatable.updateBounds(null, null)
|
||||||
|
panYAnimatable.updateBounds(null, null)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
launch { zoomAnimatable.snapTo(resetCamera.zoom) }
|
||||||
|
launch { panXAnimatable.snapTo(resetCamera.panX) }
|
||||||
|
launch { panYAnimatable.snapTo(resetCamera.panY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentPage = targetPageIdx
|
||||||
|
hasRestoredLockedState = true
|
||||||
|
onZoomChange(resetCamera.zoom)
|
||||||
|
onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY))
|
||||||
|
Timber.tag("PdfLockDiagnostic").i(
|
||||||
|
"Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (!isInitialLayout && !isScrollLocked) {
|
||||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||||
targetPageDuringResize.intValue
|
targetPageDuringResize.intValue
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -466,6 +563,7 @@ internal fun PdfVerticalReader(
|
||||||
if (!isInitialLayout) {
|
if (!isInitialLayout) {
|
||||||
delay(50)
|
delay(50)
|
||||||
isResizing = false
|
isResizing = false
|
||||||
|
lockedOrientationChangedDuringResize = false
|
||||||
targetPageDuringResize.intValue = -1
|
targetPageDuringResize.intValue = -1
|
||||||
}
|
}
|
||||||
isInitialLayout = false
|
isInitialLayout = false
|
||||||
|
|
@ -1080,8 +1178,9 @@ internal fun PdfVerticalReader(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
|
||||||
.then(globalDrawingModifier)
|
.then(globalDrawingModifier)
|
||||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode) {
|
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
|
||||||
Timber.tag("PdfTouchDebug").v(
|
Timber.tag("PdfTouchDebug").v(
|
||||||
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
||||||
)
|
)
|
||||||
|
|
@ -1101,8 +1200,10 @@ internal fun PdfVerticalReader(
|
||||||
onPageClick()
|
onPageClick()
|
||||||
}
|
}
|
||||||
}, onDoubleTap = { offset ->
|
}, onDoubleTap = { offset ->
|
||||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
if (!isScrollLocked) {
|
||||||
onDoubleTapToZoom(offset)
|
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||||
|
onDoubleTapToZoom(offset)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.pointerInput(
|
.pointerInput(
|
||||||
|
|
@ -1428,11 +1529,16 @@ internal fun PdfVerticalReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
val cached = cachedVisiblePages.value
|
val cached = cachedVisiblePages.value
|
||||||
val indicesMatch = cached.size == finalPages.size && cached.indices.all {
|
val layoutMatches = cached.size == finalPages.size && cached.indices.all {
|
||||||
cached[it].index == finalPages[it].index
|
val cachedPage = cached[it]
|
||||||
|
val newPage = finalPages[it]
|
||||||
|
cachedPage.index == newPage.index &&
|
||||||
|
cachedPage.yPx == newPage.yPx &&
|
||||||
|
cachedPage.heightPx == newPage.heightPx &&
|
||||||
|
cachedPage.widthPx == newPage.widthPx
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!indicesMatch) {
|
if (!layoutMatches) {
|
||||||
cachedVisiblePages.value = finalPages
|
cachedVisiblePages.value = finalPages
|
||||||
Timber.tag("PdfDrawPerf").d(
|
Timber.tag("PdfDrawPerf").d(
|
||||||
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
||||||
|
|
@ -1472,20 +1578,13 @@ internal fun PdfVerticalReader(
|
||||||
Layout(
|
Layout(
|
||||||
content = {
|
content = {
|
||||||
visiblePages.forEach { page ->
|
visiblePages.forEach { page ->
|
||||||
key(page.index) {
|
key(documentKey, page.index) {
|
||||||
val isBookmarked by remember(bookmarkSet, page.index) {
|
val isBookmarked by remember(bookmarkSet, page.index) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
bookmarkSet.any { it.pageIndex == page.index }
|
bookmarkSet.any { it.pageIndex == page.index }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SideEffect {
|
|
||||||
if (page.index == state.currentPage) {
|
|
||||||
Timber.tag("PdfDrawPerf")
|
|
||||||
.v("VERTICAL READER: Emitting Page ${page.index}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
|
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
|
||||||
{
|
{
|
||||||
val zoom = zoomAnimatable.value
|
val zoom = zoomAnimatable.value
|
||||||
|
|
@ -1579,6 +1678,7 @@ internal fun PdfVerticalReader(
|
||||||
{ text: String -> onSearchText(text) }
|
{ text: String -> onSearchText(text) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||||
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
||||||
{ localOffset: Offset ->
|
{ localOffset: Offset ->
|
||||||
Timber.tag("PdfZoomDebug").d(
|
Timber.tag("PdfZoomDebug").d(
|
||||||
|
|
@ -1592,7 +1692,7 @@ internal fun PdfVerticalReader(
|
||||||
val screenX = contentX * currentZ + panX
|
val screenX = contentX * currentZ + panX
|
||||||
val screenY = contentY * currentZ + panY
|
val screenY = contentY * currentZ + panY
|
||||||
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
||||||
onDoubleTapToZoom(Offset(screenX, screenY))
|
currentOnDoubleTapToZoom(Offset(screenX, screenY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1661,32 +1761,10 @@ internal fun PdfVerticalReader(
|
||||||
|
|
||||||
Box(modifier = Modifier
|
Box(modifier = Modifier
|
||||||
.layoutId(page)
|
.layoutId(page)
|
||||||
.graphicsLayer {
|
) {
|
||||||
val z = zoomAnimatable.value
|
|
||||||
val px = panXAnimatable.value
|
|
||||||
val py = panYAnimatable.value
|
|
||||||
|
|
||||||
scaleX = z
|
|
||||||
scaleY = z
|
|
||||||
translationX = px
|
|
||||||
translationY = page.y * (z - 1f) + py
|
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
|
||||||
|
|
||||||
if (page.index < 2 && z > 1.1f) {
|
|
||||||
Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipToBounds()
|
|
||||||
.onGloballyPositioned { coordinates ->
|
|
||||||
if (page.index == 0) {
|
|
||||||
val pos = coordinates.positionInWindow()
|
|
||||||
Timber.d(
|
|
||||||
"Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
PdfPageComposable(
|
PdfPageComposable(
|
||||||
pdfDocument = pdfDocument,
|
pdfDocument = pdfDocument,
|
||||||
|
documentKey = documentKey,
|
||||||
pageIndex = page.index,
|
pageIndex = page.index,
|
||||||
virtualPage = virtualPage,
|
virtualPage = virtualPage,
|
||||||
totalPages = totalPages,
|
totalPages = totalPages,
|
||||||
|
|
@ -1716,6 +1794,8 @@ internal fun PdfVerticalReader(
|
||||||
isZoomEnabled = false,
|
isZoomEnabled = false,
|
||||||
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
||||||
isVerticalScroll = true,
|
isVerticalScroll = true,
|
||||||
|
showPageNumberOverlay = showPageNumberOverlay,
|
||||||
|
isScrollLocked = isScrollLocked,
|
||||||
visualScaleProvider = currentScaleProvider,
|
visualScaleProvider = currentScaleProvider,
|
||||||
onDoubleTap = onDoubleTapLambda,
|
onDoubleTap = onDoubleTapLambda,
|
||||||
clearSelectionTrigger = selectionClearTrigger,
|
clearSelectionTrigger = selectionClearTrigger,
|
||||||
|
|
@ -1734,11 +1814,11 @@ internal fun PdfVerticalReader(
|
||||||
isStylusOnlyMode = isStylusOnlyMode,
|
isStylusOnlyMode = isStylusOnlyMode,
|
||||||
stylusButtonHovering = stylusButtonHovering,
|
stylusButtonHovering = stylusButtonHovering,
|
||||||
isAutoScrollPlaying = isAutoScrollPlaying,
|
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||||
textBoxes = textBoxes.filter { it.pageIndex == page.index },
|
textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(),
|
||||||
selectedTextBoxId = selectedTextBoxId,
|
selectedTextBoxId = selectedTextBoxId,
|
||||||
onTextBoxChange = onTextBoxChange,
|
onTextBoxChange = onTextBoxChange,
|
||||||
onTextBoxSelect = onTextBoxSelect,
|
onTextBoxSelect = onTextBoxSelect,
|
||||||
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(),
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
|
@ -1852,26 +1932,17 @@ internal fun PdfVerticalReader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.index < totalPages - 1) {
|
if (page.index < totalPages - 1 && dividerHeightPxInt > 0) {
|
||||||
val dividerY = page.y + page.height
|
val dividerYPx = page.yPx + page.heightPx
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutId(
|
.layoutId(
|
||||||
DividerLayout(
|
DividerLayout(
|
||||||
dividerY, page.width, dividerHeightPx
|
yPx = dividerYPx,
|
||||||
|
widthPx = page.widthPx,
|
||||||
|
heightPx = dividerHeightPxInt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.graphicsLayer {
|
|
||||||
val z = zoomAnimatable.value
|
|
||||||
val px = panXAnimatable.value
|
|
||||||
val py = panYAnimatable.value
|
|
||||||
|
|
||||||
scaleX = z
|
|
||||||
scaleY = z
|
|
||||||
translationX = px
|
|
||||||
translationY = dividerY * (z - 1f) + py
|
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
|
||||||
}
|
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
))
|
))
|
||||||
|
|
@ -1881,6 +1952,15 @@ internal fun PdfVerticalReader(
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
val z = zoomAnimatable.value
|
||||||
|
|
||||||
|
scaleX = z
|
||||||
|
scaleY = z
|
||||||
|
translationX = panXAnimatable.value
|
||||||
|
translationY = panYAnimatable.value
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
}
|
||||||
.onGloballyPositioned { _ -> }) { measurables, constraints ->
|
.onGloballyPositioned { _ -> }) { measurables, constraints ->
|
||||||
val layoutStart = System.nanoTime()
|
val layoutStart = System.nanoTime()
|
||||||
Timber.tag("PdfDrawPerf")
|
Timber.tag("PdfDrawPerf")
|
||||||
|
|
@ -1891,19 +1971,19 @@ internal fun PdfVerticalReader(
|
||||||
is PdfPageLayout -> {
|
is PdfPageLayout -> {
|
||||||
val placeable = measurable.measure(
|
val placeable = measurable.measure(
|
||||||
Constraints.fixed(
|
Constraints.fixed(
|
||||||
id.width.roundToInt(), id.height.roundToInt()
|
id.widthPx, id.heightPx
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
placeable.place(0, id.y.roundToInt())
|
placeable.place(0, id.yPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
is DividerLayout -> {
|
is DividerLayout -> {
|
||||||
val placeable = measurable.measure(
|
val placeable = measurable.measure(
|
||||||
Constraints.fixed(
|
Constraints.fixed(
|
||||||
id.width.roundToInt(), id.height.roundToInt()
|
id.widthPx, id.heightPx
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
placeable.place(0, id.y.roundToInt())
|
placeable.place(0, id.yPx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -198,13 +197,13 @@ import com.aryan.reader.AiDefinitionPopup
|
||||||
import com.aryan.reader.AiFeature
|
import com.aryan.reader.AiFeature
|
||||||
import com.aryan.reader.AiDefinitionResult
|
import com.aryan.reader.AiDefinitionResult
|
||||||
import com.aryan.reader.AiHubBottomSheet
|
import com.aryan.reader.AiHubBottomSheet
|
||||||
import com.aryan.reader.BannerMessage
|
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
import com.aryan.reader.CustomTopBanner
|
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.HighlightColorPickerDialog
|
import com.aryan.reader.HighlightColorPickerDialog
|
||||||
import com.aryan.reader.MainViewModel
|
import com.aryan.reader.MainViewModel
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.ReaderScreenOrientationEffect
|
||||||
|
import com.aryan.reader.ReaderScreenOrientationSheet
|
||||||
import com.aryan.reader.ReaderThemePanel
|
import com.aryan.reader.ReaderThemePanel
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
|
|
@ -225,6 +224,8 @@ import com.aryan.reader.callByokGeminiInlineAi
|
||||||
import com.aryan.reader.isByokCloudTtsAvailable
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
import com.aryan.reader.loadCustomThemes
|
import com.aryan.reader.loadCustomThemes
|
||||||
import com.aryan.reader.loadGlobalTextureTransparency
|
import com.aryan.reader.loadGlobalTextureTransparency
|
||||||
|
import com.aryan.reader.loadReaderScreenOrientationMode
|
||||||
|
import com.aryan.reader.loadPdfRightToLeftPagination
|
||||||
import com.aryan.reader.loadTtsReplacementPreferences
|
import com.aryan.reader.loadTtsReplacementPreferences
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
||||||
|
|
@ -240,7 +241,10 @@ import com.aryan.reader.pdf.data.VirtualPage
|
||||||
import com.aryan.reader.rememberSearchState
|
import com.aryan.reader.rememberSearchState
|
||||||
import com.aryan.reader.saveCustomThemes
|
import com.aryan.reader.saveCustomThemes
|
||||||
import com.aryan.reader.saveGlobalTextureTransparency
|
import com.aryan.reader.saveGlobalTextureTransparency
|
||||||
|
import com.aryan.reader.saveReaderScreenOrientationMode
|
||||||
|
import com.aryan.reader.savePdfRightToLeftPagination
|
||||||
import com.aryan.reader.saveTtsReplacementPreferences
|
import com.aryan.reader.saveTtsReplacementPreferences
|
||||||
|
import com.aryan.reader.scaledToCanvasLimit
|
||||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
import com.aryan.reader.summarizationUrl
|
import com.aryan.reader.summarizationUrl
|
||||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||||
|
|
@ -288,6 +292,36 @@ internal fun resolveEraserStrokeWidth(
|
||||||
eraserToolThickness: Float
|
eraserToolThickness: Float
|
||||||
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||||
|
|
||||||
|
internal fun canUsePdfSidecarsForBook(
|
||||||
|
activeBookId: String?,
|
||||||
|
loadedSidecarBookId: String?,
|
||||||
|
areSidecarsLoaded: Boolean
|
||||||
|
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
|
||||||
|
|
||||||
|
internal fun currentPageScaleAfterPdfPageChange(
|
||||||
|
displayMode: DisplayMode,
|
||||||
|
isScrollLocked: Boolean,
|
||||||
|
lockedState: Triple<Float, Float, Float>?,
|
||||||
|
currentActiveScale: Float
|
||||||
|
): Float {
|
||||||
|
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
|
||||||
|
lockedState?.first ?: currentActiveScale
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||||
|
isScrollLocked: Boolean,
|
||||||
|
lockedState: Triple<Float, Float, Float>?
|
||||||
|
): Pair<Float, Offset> {
|
||||||
|
return if (isScrollLocked && lockedState != null) {
|
||||||
|
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||||
|
} else {
|
||||||
|
1f to Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions")
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
|
|
@ -305,7 +339,6 @@ fun PdfViewerScreen(
|
||||||
onNavigateToPro: () -> Unit,
|
onNavigateToPro: () -> Unit,
|
||||||
viewModel: MainViewModel
|
viewModel: MainViewModel
|
||||||
) {
|
) {
|
||||||
SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") }
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
PdfFontCache.init(context.assets)
|
PdfFontCache.init(context.assets)
|
||||||
|
|
@ -338,7 +371,12 @@ fun PdfViewerScreen(
|
||||||
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
|
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
|
||||||
var showBars by rememberSaveable { mutableStateOf(true) }
|
var showBars by rememberSaveable { mutableStateOf(true) }
|
||||||
var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) }
|
var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) }
|
||||||
|
var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) }
|
||||||
|
var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) }
|
||||||
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||||
|
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
||||||
|
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
||||||
|
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
||||||
var isFullScreen by remember { mutableStateOf(false) }
|
var isFullScreen by remember { mutableStateOf(false) }
|
||||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
|
|
@ -349,6 +387,7 @@ fun PdfViewerScreen(
|
||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
var isPasswordError by remember { mutableStateOf(false) }
|
var isPasswordError by remember { mutableStateOf(false) }
|
||||||
LocalView.current
|
LocalView.current
|
||||||
|
ReaderScreenOrientationEffect(screenOrientationMode)
|
||||||
|
|
||||||
var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) }
|
var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) }
|
||||||
var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) }
|
var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) }
|
||||||
|
|
@ -397,6 +436,7 @@ fun PdfViewerScreen(
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||||
|
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
|
||||||
|
|
||||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
|
@ -404,6 +444,7 @@ fun PdfViewerScreen(
|
||||||
val isTabsEnabled = uiState.isTabsEnabled
|
val isTabsEnabled = uiState.isTabsEnabled
|
||||||
val openTabs = uiState.openTabs
|
val openTabs = uiState.openTabs
|
||||||
val activeTabBookId = uiState.activeTabBookId
|
val activeTabBookId = uiState.activeTabBookId
|
||||||
|
val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF
|
||||||
val originalFileName by remember(uiState.recentFiles, effectivePdfUri) {
|
val originalFileName by remember(uiState.recentFiles, effectivePdfUri) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName
|
uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName
|
||||||
|
|
@ -412,6 +453,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
var currentBookId by remember { mutableStateOf<String?>(null) }
|
var currentBookId by remember { mutableStateOf<String?>(null) }
|
||||||
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
|
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
|
||||||
|
val activeDocumentRenderKey = currentBookId ?: effectivePdfUri.toString()
|
||||||
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
|
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
var isDockDragging by remember { mutableStateOf(false) }
|
var isDockDragging by remember { mutableStateOf(false) }
|
||||||
|
|
@ -425,8 +467,16 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(bookId) {
|
LaunchedEffect(bookId) {
|
||||||
isScrollLocked = loadPdfScrollLocked(context, bookId)
|
val savedIsScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||||
lockedState = loadPdfLockedState(context, bookId)
|
val savedLockedState = loadPdfLockedState(context, bookId)
|
||||||
|
val activeCamera = activePdfCameraAfterLockPreferenceLoad(
|
||||||
|
isScrollLocked = savedIsScrollLocked,
|
||||||
|
lockedState = savedLockedState
|
||||||
|
)
|
||||||
|
isScrollLocked = savedIsScrollLocked
|
||||||
|
lockedState = savedLockedState
|
||||||
|
currentActiveScale = activeCamera.first
|
||||||
|
currentActiveOffset = activeCamera.second
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
||||||
|
|
@ -496,9 +546,8 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
val customFonts by viewModel.customFonts.collectAsState()
|
val customFonts by viewModel.customFonts.collectAsState()
|
||||||
|
|
||||||
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
|
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
|
||||||
fun showBanner(message: String, isError: Boolean = false) {
|
viewModel.showBanner(message, isError, isPersistent)
|
||||||
bannerMessage = BannerMessage(message, isError = isError)
|
|
||||||
}
|
}
|
||||||
val onOcrStateChange: (Boolean) -> Unit = {}
|
val onOcrStateChange: (Boolean) -> Unit = {}
|
||||||
|
|
||||||
|
|
@ -638,6 +687,13 @@ fun PdfViewerScreen(
|
||||||
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
||||||
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(isComicFile) {
|
||||||
|
if (!isComicFile) {
|
||||||
|
isBubbleZoomModeActive = false
|
||||||
|
showBubbleZoomDownloadDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
||||||
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
||||||
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
|
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
|
||||||
|
|
@ -725,7 +781,8 @@ fun PdfViewerScreen(
|
||||||
val targetTopOverlayInset = remember(
|
val targetTopOverlayInset = remember(
|
||||||
showStandardBars,
|
showStandardBars,
|
||||||
systemUiMode,
|
systemUiMode,
|
||||||
statusBarHeightDp
|
statusBarHeightDp,
|
||||||
|
isPdfTabStripVisible
|
||||||
) {
|
) {
|
||||||
if (!showStandardBars) {
|
if (!showStandardBars) {
|
||||||
0.dp
|
0.dp
|
||||||
|
|
@ -737,6 +794,9 @@ fun PdfViewerScreen(
|
||||||
if (isStatusBarVisible) {
|
if (isStatusBarVisible) {
|
||||||
inset += statusBarHeightDp
|
inset += statusBarHeightDp
|
||||||
}
|
}
|
||||||
|
if (isPdfTabStripVisible) {
|
||||||
|
inset += PdfTabStripHeight
|
||||||
|
}
|
||||||
inset
|
inset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -775,13 +835,14 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
||||||
|
|
||||||
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) {
|
LaunchedEffect(bookId, currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||||
if (isScrollLocked) {
|
if (isScrollLocked) {
|
||||||
|
val requestedCamera = currentActiveScale to currentActiveOffset
|
||||||
delay(500)
|
delay(500)
|
||||||
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}")
|
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=${requestedCamera.first} | X=${requestedCamera.second.x} | Y=${requestedCamera.second.y}")
|
||||||
|
|
||||||
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
lockedState = Triple(requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||||
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
savePdfLockedState(context, bookId, requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,14 +861,6 @@ fun PdfViewerScreen(
|
||||||
errorMessage?.let { showBanner(it, isError = true) }
|
errorMessage?.let { showBanner(it, isError = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(bannerMessage) {
|
|
||||||
val message = bannerMessage ?: return@LaunchedEffect
|
|
||||||
if (!message.isPersistent) {
|
|
||||||
delay(2500L)
|
|
||||||
bannerMessage = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
||||||
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
||||||
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -861,6 +914,7 @@ fun PdfViewerScreen(
|
||||||
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
|
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
|
||||||
|
|
||||||
var areAnnotationsLoaded by remember { mutableStateOf(false) }
|
var areAnnotationsLoaded by remember { mutableStateOf(false) }
|
||||||
|
var loadedSidecarBookId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
||||||
val richTextController = remember(currentBookId) {
|
val richTextController = remember(currentBookId) {
|
||||||
|
|
@ -936,16 +990,10 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildSpeechBubblePrefetchOrder(): List<Int> {
|
fun buildSpeechBubblePrefetchOrder(): List<Int> {
|
||||||
if (totalDisplayPages <= 0) return emptyList()
|
return buildPdfBubblePrefetchOrder(
|
||||||
val ordered = LinkedHashSet<Int>()
|
currentPage = currentPage,
|
||||||
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
|
totalPages = totalDisplayPages
|
||||||
for (distance in 1 until totalDisplayPages) {
|
)
|
||||||
val next = currentPage + distance
|
|
||||||
val previous = currentPage - distance
|
|
||||||
if (next in 0 until totalDisplayPages) ordered += next
|
|
||||||
if (previous in 0 until totalDisplayPages) ordered += previous
|
|
||||||
}
|
|
||||||
return ordered.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun detectSpeechBubblesForPage(
|
suspend fun detectSpeechBubblesForPage(
|
||||||
|
|
@ -1125,39 +1173,80 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } }
|
val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } }
|
||||||
|
|
||||||
|
val sidecarsReadyForCurrentBook =
|
||||||
|
canUsePdfSidecarsForBook(currentBookId, loadedSidecarBookId, areAnnotationsLoaded)
|
||||||
|
val textBoxesSnapshot by remember { derivedStateOf { textBoxes.toList() } }
|
||||||
|
val userHighlightsSnapshot by remember { derivedStateOf { userHighlights.toList() } }
|
||||||
|
val visibleAllAnnotations = if (sidecarsReadyForCurrentBook) allAnnotations else emptyMap()
|
||||||
|
val visibleTextBoxes = if (sidecarsReadyForCurrentBook) textBoxesSnapshot else emptyList()
|
||||||
|
val visibleUserHighlights = if (sidecarsReadyForCurrentBook) userHighlightsSnapshot else emptyList()
|
||||||
|
val visibleTextBoxesByPage = remember(sidecarsReadyForCurrentBook, textBoxesSnapshot) {
|
||||||
|
if (sidecarsReadyForCurrentBook) {
|
||||||
|
textBoxesSnapshot.groupBy { it.pageIndex }
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val visibleUserHighlightsByPage = remember(sidecarsReadyForCurrentBook, userHighlightsSnapshot) {
|
||||||
|
if (sidecarsReadyForCurrentBook) {
|
||||||
|
userHighlightsSnapshot.groupBy { it.pageIndex }
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentAnnotations by rememberUpdatedState(allAnnotations)
|
val currentAnnotations by rememberUpdatedState(allAnnotations)
|
||||||
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
|
val currentTextBoxes by rememberUpdatedState(textBoxesSnapshot)
|
||||||
val currentHighlights by rememberUpdatedState(userHighlights.toList())
|
val currentHighlights by rememberUpdatedState(userHighlightsSnapshot)
|
||||||
|
val currentLoadedSidecarBookId by rememberUpdatedState(loadedSidecarBookId)
|
||||||
|
val currentAreAnnotationsLoaded by rememberUpdatedState(areAnnotationsLoaded)
|
||||||
val currentBookmarks by rememberUpdatedState(bookmarks)
|
val currentBookmarks by rememberUpdatedState(bookmarks)
|
||||||
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
|
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
|
||||||
val currentPageState by rememberUpdatedState(currentPage)
|
val currentPageState by rememberUpdatedState(currentPage)
|
||||||
val currentPendingPage by rememberUpdatedState(pendingRestorePage)
|
val currentPendingPage by rememberUpdatedState(pendingRestorePage)
|
||||||
|
val currentVisibleAllAnnotations by rememberUpdatedState(visibleAllAnnotations)
|
||||||
|
|
||||||
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
|
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
|
||||||
{ force: Boolean ->
|
{ force: Boolean ->
|
||||||
|
val bookIdSnapshot = currentBookId
|
||||||
|
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
|
||||||
|
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
|
||||||
|
bookIdSnapshot,
|
||||||
|
loadedSidecarBookIdSnapshot,
|
||||||
|
currentAreAnnotationsLoaded
|
||||||
|
)
|
||||||
|
val isDocumentReadySnapshot = isDocumentReady
|
||||||
|
val initialScrollDoneSnapshot = initialScrollDone
|
||||||
|
val annotsSnapshot = currentAnnotations
|
||||||
|
val boxesSnapshot = currentTextBoxes
|
||||||
|
val highlightsSnapshot = currentHighlights
|
||||||
|
val bookmarksSnapshot = currentBookmarks
|
||||||
|
val totalPagesSnapshot = currentTotalPages
|
||||||
|
val currentPageSnapshot = currentPageState
|
||||||
|
val pendingPageSnapshot = currentPendingPage
|
||||||
viewModel.viewModelScope.launch {
|
viewModel.viewModelScope.launch {
|
||||||
val bookId = currentBookId ?: return@launch
|
val bookId = bookIdSnapshot ?: return@launch
|
||||||
|
|
||||||
if (!isDocumentReady && !force) {
|
if (!isDocumentReadySnapshot && !force) {
|
||||||
Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.")
|
Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val annots = currentAnnotations
|
val annots = annotsSnapshot
|
||||||
val boxes = currentTextBoxes
|
val boxes = boxesSnapshot
|
||||||
val highlights = currentHighlights
|
val highlights = highlightsSnapshot
|
||||||
val bms = currentBookmarks
|
val bms = bookmarksSnapshot
|
||||||
val totalPgs = currentTotalPages
|
val totalPgs = totalPagesSnapshot
|
||||||
|
|
||||||
val restoreTarget = currentPendingPage ?: 0
|
val restoreTarget = pendingPageSnapshot ?: 0
|
||||||
val page = if (!initialScrollDone) {
|
val page = if (!initialScrollDoneSnapshot) {
|
||||||
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)")
|
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageSnapshot)")
|
||||||
restoreTarget
|
restoreTarget
|
||||||
} else {
|
} else {
|
||||||
currentPageState
|
currentPageSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)")
|
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageSnapshot, Target: $restoreTarget, Done: $initialScrollDoneSnapshot)")
|
||||||
|
|
||||||
val annotsHash = annots.hashCode()
|
val annotsHash = annots.hashCode()
|
||||||
val boxesHash = boxes.hashCode()
|
val boxesHash = boxes.hashCode()
|
||||||
|
|
@ -1169,20 +1258,26 @@ fun PdfViewerScreen(
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@Suppress("VariableNeverRead") var didSave = false
|
@Suppress("VariableNeverRead") var didSave = false
|
||||||
|
|
||||||
if (force || annotsHash != lastSavedHashes[0]) {
|
if (canSaveSidecarsSnapshot) {
|
||||||
annotationRepository.saveAnnotations(bookId, annots)
|
if (force || annotsHash != lastSavedHashes[0]) {
|
||||||
lastSavedHashes[0] = annotsHash
|
annotationRepository.saveAnnotations(bookId, annots)
|
||||||
didSave = true
|
lastSavedHashes[0] = annotsHash
|
||||||
}
|
didSave = true
|
||||||
if (force || boxesHash != lastSavedHashes[1]) {
|
}
|
||||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
if (force || boxesHash != lastSavedHashes[1]) {
|
||||||
lastSavedHashes[1] = boxesHash
|
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||||
didSave = true
|
lastSavedHashes[1] = boxesHash
|
||||||
}
|
didSave = true
|
||||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
}
|
||||||
highlightRepository.saveHighlights(bookId, highlights)
|
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||||
lastSavedHashes[2] = highlightsHash
|
highlightRepository.saveHighlights(bookId, highlights)
|
||||||
didSave = true
|
lastSavedHashes[2] = highlightsHash
|
||||||
|
didSave = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.tag("PdfTabSync").d(
|
||||||
|
"Skipping PDF sidecar save for $bookId; loaded sidecars belong to $loadedSidecarBookIdSnapshot"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (force || bmsHash != lastSavedHashes[3]) {
|
if (force || bmsHash != lastSavedHashes[3]) {
|
||||||
val objectList = bms.map { bookmark ->
|
val objectList = bms.map { bookmark ->
|
||||||
|
|
@ -1239,18 +1334,19 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
allAnnotations,
|
allAnnotations,
|
||||||
textBoxes.toList(),
|
textBoxesSnapshot,
|
||||||
userHighlights.toList(),
|
userHighlightsSnapshot,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
currentPage
|
currentPage,
|
||||||
|
sidecarsReadyForCurrentBook
|
||||||
) {
|
) {
|
||||||
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
|
if (sidecarsReadyForCurrentBook && initialScrollDone) {
|
||||||
delay(2000) // Debounce period
|
delay(2000) // Debounce period
|
||||||
saveAllData(false)
|
saveAllData(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val allAnnotationsProvider = remember { { allAnnotations } }
|
val allAnnotationsProvider = remember { { currentVisibleAllAnnotations } }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
||||||
|
|
@ -2070,26 +2166,39 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(currentBookId) {
|
LaunchedEffect(currentBookId) {
|
||||||
if (currentBookId != null) {
|
val loadingBookId = currentBookId
|
||||||
val loaded = annotationRepository.loadAnnotations(currentBookId!!)
|
|
||||||
allAnnotations = loaded
|
|
||||||
areAnnotationsLoaded = true
|
|
||||||
|
|
||||||
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
areAnnotationsLoaded = false
|
||||||
textBoxes.clear()
|
loadedSidecarBookId = null
|
||||||
textBoxes.addAll(loadedBoxes)
|
allAnnotations = emptyMap()
|
||||||
|
textBoxes.clear()
|
||||||
|
userHighlights.clear()
|
||||||
|
selectedTextBoxId = null
|
||||||
|
undoStack.clear()
|
||||||
|
redoStack.clear()
|
||||||
|
erasedAnnotationsFromStroke.clear()
|
||||||
|
drawingState.onDrawCancel()
|
||||||
|
|
||||||
val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!)
|
if (loadingBookId == null) return@LaunchedEffect
|
||||||
userHighlights.clear()
|
|
||||||
userHighlights.addAll(loadedHighlights)
|
val loaded = annotationRepository.loadAnnotations(loadingBookId)
|
||||||
}
|
val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId)
|
||||||
|
val loadedHighlights = highlightRepository.loadHighlights(loadingBookId)
|
||||||
|
|
||||||
|
if (currentBookId != loadingBookId) return@LaunchedEffect
|
||||||
|
|
||||||
|
allAnnotations = loaded
|
||||||
|
textBoxes.addAll(loadedBoxes)
|
||||||
|
userHighlights.addAll(loadedHighlights)
|
||||||
|
loadedSidecarBookId = loadingBookId
|
||||||
|
areAnnotationsLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
|
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
|
||||||
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
|
LaunchedEffect(pdfDocument, currentBookId, userHighlightsSnapshot, sidecarsReadyForCurrentBook) {
|
||||||
val document = pdfDocument ?: return@LaunchedEffect
|
val document = pdfDocument ?: return@LaunchedEffect
|
||||||
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
if (!sidecarsReadyForCurrentBook || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
||||||
val snapshot = userHighlights.toList()
|
val snapshot = userHighlightsSnapshot
|
||||||
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
|
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
|
||||||
|
|
||||||
isRebuildingSyncedHighlightBounds = true
|
isRebuildingSyncedHighlightBounds = true
|
||||||
|
|
@ -2116,18 +2225,18 @@ fun PdfViewerScreen(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||||
|
|
||||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||||
if (userHighlights.isEmpty()) {
|
if (visibleUserHighlights.isEmpty()) {
|
||||||
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.savePdfWithAnnotations(
|
viewModel.savePdfWithAnnotations(
|
||||||
sourceUri = effectivePdfUri,
|
sourceUri = effectivePdfUri,
|
||||||
destUri = uri,
|
destUri = uri,
|
||||||
annotations = allAnnotations,
|
annotations = visibleAllAnnotations,
|
||||||
richTextPageLayouts = currentRichTextLayouts,
|
richTextPageLayouts = currentRichTextLayouts,
|
||||||
textBoxes = textBoxes.toList(),
|
textBoxes = visibleTextBoxes,
|
||||||
highlights = userHighlights.toList(),
|
highlights = visibleUserHighlights,
|
||||||
bookId = currentBookId!!
|
bookId = currentBookId!!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2879,6 +2988,17 @@ fun PdfViewerScreen(
|
||||||
isDocumentReady = false
|
isDocumentReady = false
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
documentMetadataTitle = null
|
documentMetadataTitle = null
|
||||||
|
currentBookId = null
|
||||||
|
areAnnotationsLoaded = false
|
||||||
|
loadedSidecarBookId = null
|
||||||
|
allAnnotations = emptyMap()
|
||||||
|
textBoxes.clear()
|
||||||
|
userHighlights.clear()
|
||||||
|
selectedTextBoxId = null
|
||||||
|
undoStack.clear()
|
||||||
|
redoStack.clear()
|
||||||
|
erasedAnnotationsFromStroke.clear()
|
||||||
|
drawingState.onDrawCancel()
|
||||||
|
|
||||||
if (showPasswordDialog) isPasswordError = false
|
if (showPasswordDialog) isPasswordError = false
|
||||||
|
|
||||||
|
|
@ -2932,12 +3052,13 @@ fun PdfViewerScreen(
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
|
Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
|
||||||
|
|
||||||
if (pdfUri.scheme != "opds-pse") {
|
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
||||||
|
if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) {
|
||||||
currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||||
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
||||||
}
|
}
|
||||||
|
|
||||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
@ -3027,7 +3148,7 @@ fun PdfViewerScreen(
|
||||||
currentBookId!!,
|
currentBookId!!,
|
||||||
DocumentCacheItem(
|
DocumentCacheItem(
|
||||||
doc = doc,
|
doc = doc,
|
||||||
pfd = currentPfdOpened!!,
|
pfd = currentPfdOpened,
|
||||||
totalPages = pagesCount,
|
totalPages = pagesCount,
|
||||||
pageAspectRatios = ratios,
|
pageAspectRatios = ratios,
|
||||||
flatTableOfContents = flatTableOfContents
|
flatTableOfContents = flatTableOfContents
|
||||||
|
|
@ -3094,8 +3215,8 @@ fun PdfViewerScreen(
|
||||||
isLoadingDocument = false
|
isLoadingDocument = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.e(e, "Error loading PDF document")
|
Timber.e(e, "Error loading fixed-layout document")
|
||||||
errorMessage = "Error loading PDF: ${e.localizedMessage}"
|
errorMessage = "Error loading document: ${e.localizedMessage}"
|
||||||
isLoadingDocument = false
|
isLoadingDocument = false
|
||||||
}
|
}
|
||||||
if (pdfDocument == null) {
|
if (pdfDocument == null) {
|
||||||
|
|
@ -3132,8 +3253,14 @@ fun PdfViewerScreen(
|
||||||
summarizationResult = null
|
summarizationResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) {
|
||||||
currentPageScale = 1f
|
val nextPageScale = currentPageScaleAfterPdfPageChange(
|
||||||
|
displayMode = displayMode,
|
||||||
|
isScrollLocked = isScrollLocked,
|
||||||
|
lockedState = lockedState,
|
||||||
|
currentActiveScale = currentActiveScale
|
||||||
|
)
|
||||||
|
currentPageScale = nextPageScale
|
||||||
ocrUsedForCurrentPageTts = false
|
ocrUsedForCurrentPageTts = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3172,7 +3299,8 @@ fun PdfViewerScreen(
|
||||||
LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
|
LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
|
||||||
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
|
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
|
||||||
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
|
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
|
||||||
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
|
val selectedDocumentType = uiState.selectedFileType ?: return@LaunchedEffect
|
||||||
|
if (selectedDocumentType != FileType.PDF && selectedDocumentType != FileType.PPTX) return@LaunchedEffect
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
|
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
|
||||||
|
|
@ -3183,6 +3311,7 @@ fun PdfViewerScreen(
|
||||||
isBackgroundIndexing = true
|
isBackgroundIndexing = true
|
||||||
var bgPfd: ParcelFileDescriptor? = null
|
var bgPfd: ParcelFileDescriptor? = null
|
||||||
var bgDoc: PdfDocumentKt? = null
|
var bgDoc: PdfDocumentKt? = null
|
||||||
|
var genericDoc: ReaderDocument? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!)
|
val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!)
|
||||||
|
|
@ -3199,17 +3328,18 @@ fun PdfViewerScreen(
|
||||||
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
|
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
|
||||||
)
|
)
|
||||||
|
|
||||||
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
||||||
val openedBgPfd = bgPfd
|
val totalToDo = pagesToIndex.size
|
||||||
if (openedBgPfd != null) {
|
var completed = 0
|
||||||
|
|
||||||
|
if (selectedDocumentType == FileType.PDF) {
|
||||||
|
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||||
|
val openedBgPfd = bgPfd
|
||||||
|
if (openedBgPfd == null) return@withContext
|
||||||
bgDoc = PdfiumEngineProvider.withPdfium {
|
bgDoc = PdfiumEngineProvider.withPdfium {
|
||||||
pdfiumCore.newDocument(openedBgPfd, documentPassword)
|
pdfiumCore.newDocument(openedBgPfd, documentPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
|
||||||
val totalToDo = pagesToIndex.size
|
|
||||||
var completed = 0
|
|
||||||
|
|
||||||
for (pageIndex in pagesToIndex) {
|
for (pageIndex in pagesToIndex) {
|
||||||
if (!isActive) break
|
if (!isActive) break
|
||||||
|
|
||||||
|
|
@ -3223,6 +3353,37 @@ fun PdfViewerScreen(
|
||||||
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completed++
|
||||||
|
if (completed % 5 == 0 || completed == totalToDo) {
|
||||||
|
val totalIndexedSoFar = initialIndexedCount + completed
|
||||||
|
backgroundIndexingProgress =
|
||||||
|
totalIndexedSoFar.toFloat() / totalPages.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val openedGenericDoc = DocumentFactory.loadDocument(
|
||||||
|
context = context,
|
||||||
|
uri = effectivePdfUri,
|
||||||
|
type = selectedDocumentType,
|
||||||
|
password = null,
|
||||||
|
pdfiumCore = pdfiumCore
|
||||||
|
)
|
||||||
|
genericDoc = openedGenericDoc
|
||||||
|
|
||||||
|
for (pageIndex in pagesToIndex) {
|
||||||
|
if (!isActive) break
|
||||||
|
|
||||||
|
try {
|
||||||
|
pdfTextRepository.indexReaderPage(
|
||||||
|
bookId = currentBookId!!,
|
||||||
|
document = openedGenericDoc,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
onOcrModelDownloading = { isOcrModelDownloading = true }
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||||
|
}
|
||||||
|
|
||||||
completed++
|
completed++
|
||||||
if (completed % 5 == 0 || completed == totalToDo) {
|
if (completed % 5 == 0 || completed == totalToDo) {
|
||||||
val totalIndexedSoFar = initialIndexedCount + completed
|
val totalIndexedSoFar = initialIndexedCount + completed
|
||||||
|
|
@ -3238,6 +3399,7 @@ fun PdfViewerScreen(
|
||||||
PdfiumEngineProvider.withPdfium {
|
PdfiumEngineProvider.withPdfium {
|
||||||
bgDoc?.close()
|
bgDoc?.close()
|
||||||
}
|
}
|
||||||
|
genericDoc?.close()
|
||||||
bgPfd?.close()
|
bgPfd?.close()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Indexer: Cleanup failed")
|
Timber.e(e, "Indexer: Cleanup failed")
|
||||||
|
|
@ -3510,9 +3672,10 @@ fun PdfViewerScreen(
|
||||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||||
PdfNavigationDrawerContent(
|
PdfNavigationDrawerContent(
|
||||||
pdfDocument = pdfDocument,
|
pdfDocument = pdfDocument,
|
||||||
|
documentKey = activeDocumentRenderKey,
|
||||||
flatTableOfContents = flatTableOfContents,
|
flatTableOfContents = flatTableOfContents,
|
||||||
bookmarks = bookmarks,
|
bookmarks = bookmarks,
|
||||||
userHighlights = userHighlights,
|
userHighlights = visibleUserHighlights,
|
||||||
currentPage = currentPage,
|
currentPage = currentPage,
|
||||||
totalPages = totalDisplayPages,
|
totalPages = totalDisplayPages,
|
||||||
customHighlightColors = customHighlightColors,
|
customHighlightColors = customHighlightColors,
|
||||||
|
|
@ -3649,7 +3812,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfDocument != null && totalPages > 0 -> {
|
pdfDocument != null && totalPages > 0 -> {
|
||||||
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
|
val stablePdfDocument = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) }
|
||||||
when (displayMode) {
|
when (displayMode) {
|
||||||
DisplayMode.PAGINATION -> {
|
DisplayMode.PAGINATION -> {
|
||||||
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
||||||
|
|
@ -3664,7 +3827,11 @@ fun PdfViewerScreen(
|
||||||
tapOffset.x < oneQuarterWidthPx -> {
|
tapOffset.x < oneQuarterWidthPx -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetPage =
|
val targetPage =
|
||||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
if (rightToLeftPagination) {
|
||||||
|
(pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1)
|
||||||
|
} else {
|
||||||
|
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||||
|
}
|
||||||
if (targetPage != pagerState.currentPage) {
|
if (targetPage != pagerState.currentPage) {
|
||||||
pagerState.scrollToPage(targetPage)
|
pagerState.scrollToPage(targetPage)
|
||||||
}
|
}
|
||||||
|
|
@ -3675,9 +3842,13 @@ fun PdfViewerScreen(
|
||||||
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetPage =
|
val targetPage =
|
||||||
(pagerState.currentPage + 1).coerceAtMost(
|
if (rightToLeftPagination) {
|
||||||
pagerState.pageCount - 1
|
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||||
)
|
} else {
|
||||||
|
(pagerState.currentPage + 1).coerceAtMost(
|
||||||
|
pagerState.pageCount - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
if (targetPage != pagerState.currentPage) {
|
if (targetPage != pagerState.currentPage) {
|
||||||
pagerState.scrollToPage(targetPage)
|
pagerState.scrollToPage(targetPage)
|
||||||
}
|
}
|
||||||
|
|
@ -3694,14 +3865,14 @@ fun PdfViewerScreen(
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
key = { it },
|
key = { page -> "$activeDocumentRenderKey:$page" },
|
||||||
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
||||||
|
reverseLayout = rightToLeftPagination,
|
||||||
userScrollEnabled = run {
|
userScrollEnabled = run {
|
||||||
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
(currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) &&
|
||||||
SideEffect {
|
!(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) &&
|
||||||
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
|
!isPageSliderVisible &&
|
||||||
}
|
paginationDraggingBoxId == null
|
||||||
enabled
|
|
||||||
}
|
}
|
||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
|
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
|
||||||
|
|
@ -3881,6 +4052,7 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
PdfPageComposable(
|
PdfPageComposable(
|
||||||
pdfDocument = stablePdfDocument,
|
pdfDocument = stablePdfDocument,
|
||||||
|
documentKey = activeDocumentRenderKey,
|
||||||
pageIndex = pageIndex,
|
pageIndex = pageIndex,
|
||||||
virtualPage = virtualPage,
|
virtualPage = virtualPage,
|
||||||
totalPages = totalDisplayPages,
|
totalPages = totalDisplayPages,
|
||||||
|
|
@ -3923,6 +4095,7 @@ fun PdfViewerScreen(
|
||||||
isBookmarked = isPageBookmarked,
|
isBookmarked = isPageBookmarked,
|
||||||
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
||||||
isZoomEnabled = true,
|
isZoomEnabled = true,
|
||||||
|
showPageNumberOverlay = showPageNumberOverlay,
|
||||||
clearSelectionTrigger = selectionClearTrigger,
|
clearSelectionTrigger = selectionClearTrigger,
|
||||||
resetZoomTrigger = resetZoomTrigger,
|
resetZoomTrigger = resetZoomTrigger,
|
||||||
pageAnnotations = pageAnnotationsProvider,
|
pageAnnotations = pageAnnotationsProvider,
|
||||||
|
|
@ -3961,7 +4134,7 @@ fun PdfViewerScreen(
|
||||||
onOcrModelDownloading = {
|
onOcrModelDownloading = {
|
||||||
isOcrModelDownloading = true
|
isOcrModelDownloading = true
|
||||||
},
|
},
|
||||||
userHighlights = userHighlights.filter { it.pageIndex == pageIndex },
|
userHighlights = visibleUserHighlightsByPage[pageIndex].orEmpty(),
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
|
@ -3980,7 +4153,12 @@ fun PdfViewerScreen(
|
||||||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||||
},
|
},
|
||||||
onShowPanelPopup = { bitmapWithRects ->
|
onShowPanelPopup = { bitmapWithRects ->
|
||||||
poppedUpPanelBitmap = bitmapWithRects
|
val safeBitmap = bitmapWithRects.scaledToCanvasLimit()
|
||||||
|
if (safeBitmap !== bitmapWithRects && !bitmapWithRects.isRecycled) {
|
||||||
|
bitmapWithRects.recycle()
|
||||||
|
}
|
||||||
|
poppedUpPanelBitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||||
|
poppedUpPanelBitmap = safeBitmap
|
||||||
},
|
},
|
||||||
onTwoFingerSwipe = { direction ->
|
onTwoFingerSwipe = { direction ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -3999,7 +4177,7 @@ fun PdfViewerScreen(
|
||||||
isAutoScrollPlaying = isAutoScrollPlaying,
|
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||||
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
||||||
isEditMode = isDrawingActive,
|
isEditMode = isDrawingActive,
|
||||||
textBoxes = textBoxes.filter { it.pageIndex == pageIndex },
|
textBoxes = visibleTextBoxesByPage[pageIndex].orEmpty(),
|
||||||
selectedTextBoxId = selectedTextBoxId,
|
selectedTextBoxId = selectedTextBoxId,
|
||||||
onTextBoxChange = { updatedBox ->
|
onTextBoxChange = { updatedBox ->
|
||||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||||
|
|
@ -4326,7 +4504,7 @@ fun PdfViewerScreen(
|
||||||
Box(modifier = Modifier
|
Box(modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RectangleShape)) {
|
.clip(RectangleShape)) {
|
||||||
val docHolder = remember(pdfDocument) {
|
val docHolder = remember(activeDocumentRenderKey, pdfDocument) {
|
||||||
StableHolder(pdfDocument!!)
|
StableHolder(pdfDocument!!)
|
||||||
}
|
}
|
||||||
val bookmarksHolder =
|
val bookmarksHolder =
|
||||||
|
|
@ -4338,6 +4516,7 @@ fun PdfViewerScreen(
|
||||||
PdfVerticalReader(
|
PdfVerticalReader(
|
||||||
state = verticalReaderState,
|
state = verticalReaderState,
|
||||||
pdfDocument = docHolder,
|
pdfDocument = docHolder,
|
||||||
|
documentKey = activeDocumentRenderKey,
|
||||||
activeTheme = activeTheme,
|
activeTheme = activeTheme,
|
||||||
activeTextureAlpha = 1f - globalTextureTransparency,
|
activeTextureAlpha = 1f - globalTextureTransparency,
|
||||||
excludeImages = excludeImages,
|
excludeImages = excludeImages,
|
||||||
|
|
@ -4364,7 +4543,8 @@ fun PdfViewerScreen(
|
||||||
onSearchText = onSearchTextStable,
|
onSearchText = onSearchTextStable,
|
||||||
ttsHighlightData = ttsHighlightData,
|
ttsHighlightData = ttsHighlightData,
|
||||||
ttsReadingPage = ttsDisplayPageIndex,
|
ttsReadingPage = ttsDisplayPageIndex,
|
||||||
userHighlights = userHighlights,
|
userHighlights = visibleUserHighlights,
|
||||||
|
userHighlightsByPage = visibleUserHighlightsByPage,
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
|
@ -4419,7 +4599,8 @@ fun PdfViewerScreen(
|
||||||
isStylusOnlyMode = isStylusOnlyMode,
|
isStylusOnlyMode = isStylusOnlyMode,
|
||||||
stylusButtonHovering = stylusButtonHovering,
|
stylusButtonHovering = stylusButtonHovering,
|
||||||
isEditMode = isDrawingActive,
|
isEditMode = isDrawingActive,
|
||||||
textBoxes = textBoxes,
|
textBoxes = visibleTextBoxes,
|
||||||
|
textBoxesByPage = visibleTextBoxesByPage,
|
||||||
selectedTextBoxId = selectedTextBoxId,
|
selectedTextBoxId = selectedTextBoxId,
|
||||||
onTextBoxChange = { updatedBox ->
|
onTextBoxChange = { updatedBox ->
|
||||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||||
|
|
@ -4444,6 +4625,8 @@ fun PdfViewerScreen(
|
||||||
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
||||||
onInteractionListener = onAutoScrollInteraction,
|
onInteractionListener = onAutoScrollInteraction,
|
||||||
lockedState = lockedState,
|
lockedState = lockedState,
|
||||||
|
showPageGap = showVerticalPageGap,
|
||||||
|
showPageNumberOverlay = showPageNumberOverlay,
|
||||||
onZoomAndPanChanged = { newScale, newOffset ->
|
onZoomAndPanChanged = { newScale, newOffset ->
|
||||||
currentActiveScale = newScale
|
currentActiveScale = newScale
|
||||||
currentActiveOffset = newOffset
|
currentActiveOffset = newOffset
|
||||||
|
|
@ -5030,6 +5213,7 @@ fun PdfViewerScreen(
|
||||||
isScrollLocked = isScrollLocked,
|
isScrollLocked = isScrollLocked,
|
||||||
isEditMode = isEditMode,
|
isEditMode = isEditMode,
|
||||||
displayMode = displayMode,
|
displayMode = displayMode,
|
||||||
|
isRightToLeftPagination = rightToLeftPagination,
|
||||||
isKeepScreenOn = isKeepScreenOn,
|
isKeepScreenOn = isKeepScreenOn,
|
||||||
isTtsSessionActive = isTtsSessionActive,
|
isTtsSessionActive = isTtsSessionActive,
|
||||||
isBookmarked = isBookmarked,
|
isBookmarked = isBookmarked,
|
||||||
|
|
@ -5084,6 +5268,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShowVisualOptions = { showVisualOptionsSheet = true },
|
onShowVisualOptions = { showVisualOptionsSheet = true },
|
||||||
|
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||||
isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
|
isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
|
||||||
showAllTextHighlights = showAllTextHighlights,
|
showAllTextHighlights = showAllTextHighlights,
|
||||||
isHighlightingLoading = isHighlightingLoading,
|
isHighlightingLoading = isHighlightingLoading,
|
||||||
|
|
@ -5100,6 +5285,10 @@ fun PdfViewerScreen(
|
||||||
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
||||||
},
|
},
|
||||||
onChangeDisplayMode = { displayMode = it },
|
onChangeDisplayMode = { displayMode = it },
|
||||||
|
onSetRightToLeftPagination = { enabled ->
|
||||||
|
rightToLeftPagination = enabled
|
||||||
|
savePdfRightToLeftPagination(context, enabled)
|
||||||
|
},
|
||||||
onToggleKeepScreenOn = {
|
onToggleKeepScreenOn = {
|
||||||
isKeepScreenOn = !isKeepScreenOn
|
isKeepScreenOn = !isKeepScreenOn
|
||||||
saveKeepScreenOn(context, isKeepScreenOn)
|
saveKeepScreenOn(context, isKeepScreenOn)
|
||||||
|
|
@ -5215,7 +5404,11 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.msg_indexing_pages_progress),
|
text = stringResource(
|
||||||
|
R.string.msg_indexing_pages_progress,
|
||||||
|
(backgroundIndexingProgress * 100f).roundToInt()
|
||||||
|
.coerceIn(0, 100)
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
|
|
@ -5427,6 +5620,8 @@ fun PdfViewerScreen(
|
||||||
onShowAiHub = showPdfAiHub,
|
onShowAiHub = showPdfAiHub,
|
||||||
onToggleEditMode = togglePdfEditMode,
|
onToggleEditMode = togglePdfEditMode,
|
||||||
onToggleTts = togglePdfTts,
|
onToggleTts = togglePdfTts,
|
||||||
|
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||||
|
showBubbleZoom = isComicFile,
|
||||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||||
onToggleBubbleZoom = {
|
onToggleBubbleZoom = {
|
||||||
if (isOss) {
|
if (isOss) {
|
||||||
|
|
@ -6166,8 +6361,6 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomTopBanner(bannerMessage = bannerMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6314,6 +6507,17 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
// --- PANEL POPUP ---
|
// --- PANEL POPUP ---
|
||||||
if (poppedUpPanelBitmap != null) {
|
if (poppedUpPanelBitmap != null) {
|
||||||
|
val sourcePanelBitmap = poppedUpPanelBitmap
|
||||||
|
val displayPanelBitmap = remember(sourcePanelBitmap) {
|
||||||
|
sourcePanelBitmap?.scaledToCanvasLimit()
|
||||||
|
}
|
||||||
|
DisposableEffect(sourcePanelBitmap, displayPanelBitmap) {
|
||||||
|
onDispose {
|
||||||
|
if (displayPanelBitmap != null && displayPanelBitmap !== sourcePanelBitmap && !displayPanelBitmap.isRecycled) {
|
||||||
|
displayPanelBitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -6327,15 +6531,17 @@ fun PdfViewerScreen(
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Image(
|
displayPanelBitmap?.takeUnless { it.isRecycled }?.let { panelBitmap ->
|
||||||
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
|
Image(
|
||||||
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
bitmap = panelBitmap.asImageBitmap(),
|
||||||
modifier = Modifier
|
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp)),
|
.padding(16.dp)
|
||||||
contentScale = ContentScale.Fit
|
.clip(RoundedCornerShape(12.dp)),
|
||||||
)
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -6832,7 +7038,7 @@ fun PdfViewerScreen(
|
||||||
onClick = {
|
onClick = {
|
||||||
showShareDialog = false
|
showShareDialog = false
|
||||||
isShareLoading = true
|
isShareLoading = true
|
||||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||||
val filename = getSuggestedFilename(
|
val filename = getSuggestedFilename(
|
||||||
originalFileName, isAnnotated = true
|
originalFileName, isAnnotated = true
|
||||||
)
|
)
|
||||||
|
|
@ -6842,10 +7048,10 @@ fun PdfViewerScreen(
|
||||||
viewModel.sharePdf(
|
viewModel.sharePdf(
|
||||||
activityContext = context,
|
activityContext = context,
|
||||||
sourceUri = effectivePdfUri,
|
sourceUri = effectivePdfUri,
|
||||||
annotations = allAnnotations,
|
annotations = visibleAllAnnotations,
|
||||||
richTextPageLayouts = currentRichTextLayouts,
|
richTextPageLayouts = currentRichTextLayouts,
|
||||||
textBoxes = textBoxes.toList(),
|
textBoxes = visibleTextBoxes,
|
||||||
highlights = userHighlights.toList(),
|
highlights = visibleUserHighlights,
|
||||||
includeAnnotations = true,
|
includeAnnotations = true,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
bookId = currentBookId
|
bookId = currentBookId
|
||||||
|
|
@ -6869,7 +7075,7 @@ fun PdfViewerScreen(
|
||||||
viewModel.sharePdf(
|
viewModel.sharePdf(
|
||||||
activityContext = context,
|
activityContext = context,
|
||||||
sourceUri = pdfUri,
|
sourceUri = pdfUri,
|
||||||
annotations = allAnnotations,
|
annotations = emptyMap(),
|
||||||
includeAnnotations = false,
|
includeAnnotations = false,
|
||||||
filename = filename
|
filename = filename
|
||||||
)
|
)
|
||||||
|
|
@ -6917,13 +7123,33 @@ fun PdfViewerScreen(
|
||||||
if (showVisualOptionsSheet) {
|
if (showVisualOptionsSheet) {
|
||||||
PdfVisualOptionsSheet(
|
PdfVisualOptionsSheet(
|
||||||
systemUiMode = systemUiMode,
|
systemUiMode = systemUiMode,
|
||||||
|
showVerticalPageGap = showVerticalPageGap,
|
||||||
|
showPageNumberOverlay = showPageNumberOverlay,
|
||||||
onSystemUiModeChange = { mode ->
|
onSystemUiModeChange = { mode ->
|
||||||
systemUiMode = mode
|
systemUiMode = mode
|
||||||
savePdfSystemUiMode(context, mode)
|
savePdfSystemUiMode(context, mode)
|
||||||
},
|
},
|
||||||
|
onShowVerticalPageGapChange = { isVisible ->
|
||||||
|
showVerticalPageGap = isVisible
|
||||||
|
savePdfVerticalPageGapVisible(context, isVisible)
|
||||||
|
},
|
||||||
|
onShowPageNumberOverlayChange = { isVisible ->
|
||||||
|
showPageNumberOverlay = isVisible
|
||||||
|
savePdfPageNumberOverlayVisible(context, isVisible)
|
||||||
|
},
|
||||||
onDismiss = { showVisualOptionsSheet = false }
|
onDismiss = { showVisualOptionsSheet = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showScreenOrientationSheet) {
|
||||||
|
ReaderScreenOrientationSheet(
|
||||||
|
selectedMode = screenOrientationMode,
|
||||||
|
onModeSelected = { mode ->
|
||||||
|
screenOrientationMode = mode
|
||||||
|
saveReaderScreenOrientationMode(context, mode)
|
||||||
|
},
|
||||||
|
onDismiss = { showScreenOrientationSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showCustomizeToolsSheet) {
|
if (showCustomizeToolsSheet) {
|
||||||
PdfCustomizeToolsSheet(
|
PdfCustomizeToolsSheet(
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
@ -267,18 +266,18 @@ class TextPaginationEngine {
|
||||||
dirtyGlobalIndex: Int = 0
|
dirtyGlobalIndex: Int = 0
|
||||||
): List<PageTextLayout> {
|
): List<PageTextLayout> {
|
||||||
val totalLen = globalText.length
|
val totalLen = globalText.length
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
||||||
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
||||||
)
|
)
|
||||||
if (totalLen == 0) {
|
if (totalLen == 0) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
|
Timber.d("android.paginate empty -> p0:0-0")
|
||||||
return listOf(
|
return listOf(
|
||||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
Timber.d("android.paginate aborted invalid page size")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,7 +319,7 @@ class TextPaginationEngine {
|
||||||
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
||||||
}
|
}
|
||||||
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||||
|
|
||||||
return resultLayouts
|
return resultLayouts
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +349,7 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
||||||
pageHeightPx = pageHeightPx
|
pageHeightPx = pageHeightPx
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
|
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
|
||||||
)
|
)
|
||||||
return nextPageIndex + 1
|
return nextPageIndex + 1
|
||||||
|
|
@ -398,11 +397,11 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (isLastContentPage && explicitBreakEnd != null) {
|
if (isLastContentPage && explicitBreakEnd != null) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
|
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
|
||||||
)
|
)
|
||||||
} else if (!fitsOnPage) {
|
} else if (!fitsOnPage) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
|
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -475,12 +474,12 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
suspend fun load(bookId: String) {
|
suspend fun load(bookId: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val file = getFile(bookId)
|
val file = getFile(bookId)
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
|
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
|
||||||
)
|
)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
_document.value = GlobalRichDocument("", emptyList())
|
_document.value = GlobalRichDocument("", emptyList())
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
|
Timber.d("android.repository.load missing -> empty book=$bookId")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -508,11 +507,11 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_document.value = GlobalRichDocument(text, spans)
|
_document.value = GlobalRichDocument(text, spans)
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
|
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
|
Timber.e(e, "android.repository.load failed book=$bookId")
|
||||||
Timber.e(e, "Failed to load rich text doc")
|
Timber.e(e, "Failed to load rich text doc")
|
||||||
_document.value = GlobalRichDocument("", emptyList())
|
_document.value = GlobalRichDocument("", emptyList())
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +522,7 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
_document.value = document
|
_document.value = document
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
|
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
|
||||||
)
|
)
|
||||||
val obj = JSONObject().apply {
|
val obj = JSONObject().apply {
|
||||||
|
|
@ -548,11 +547,11 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
val file = getFile(bookId)
|
val file = getFile(bookId)
|
||||||
file.writeText(obj.toString())
|
file.writeText(obj.toString())
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
Timber.d(
|
||||||
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
|
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
|
Timber.e(e, "android.repository.save failed book=$bookId")
|
||||||
Timber.e(e, "Failed to save rich text doc")
|
Timber.e(e, "Failed to save rich text doc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.pptx.PptxDocumentWrapper
|
||||||
import io.legere.pdfiumandroid.api.Bookmark
|
import io.legere.pdfiumandroid.api.Bookmark
|
||||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||||
|
|
@ -79,7 +80,20 @@ object DocumentFactory {
|
||||||
val catalogId = uri.getQueryParameter("catalogId")
|
val catalogId = uri.getQueryParameter("catalogId")
|
||||||
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
|
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
|
||||||
}
|
}
|
||||||
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
return if (type == FileType.PPTX) {
|
||||||
|
val cacheFile = File(context.cacheDir, "temp_pptx_${System.currentTimeMillis()}.pptx")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: throw Exception("Failed to open PPTX")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { cacheFile.delete() }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
|
||||||
|
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
data class PdfTextBox(
|
data class PdfTextBox(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|
@ -54,7 +55,9 @@ data class PdfAnnotation(
|
||||||
val pageIndex: Int,
|
val pageIndex: Int,
|
||||||
val points: List<PdfPoint>,
|
val points: List<PdfPoint>,
|
||||||
val color: Color,
|
val color: Color,
|
||||||
val strokeWidth: Float
|
val strokeWidth: Float,
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val note: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
object AnnotationSerializer {
|
object AnnotationSerializer {
|
||||||
|
|
@ -63,11 +66,15 @@ object AnnotationSerializer {
|
||||||
annotations.forEach { (_, list) ->
|
annotations.forEach { (_, list) ->
|
||||||
list.forEach { annotation ->
|
list.forEach { annotation ->
|
||||||
val obj = JSONObject()
|
val obj = JSONObject()
|
||||||
|
obj.put("id", annotation.id)
|
||||||
obj.put("pageIndex", annotation.pageIndex)
|
obj.put("pageIndex", annotation.pageIndex)
|
||||||
obj.put("annotationType", annotation.type.name)
|
obj.put("annotationType", annotation.type.name)
|
||||||
obj.put("inkType", annotation.inkType.name)
|
obj.put("inkType", annotation.inkType.name)
|
||||||
obj.put("color", annotation.color.toArgb())
|
obj.put("color", annotation.color.toArgb())
|
||||||
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
|
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
|
||||||
|
if (!annotation.note.isNullOrBlank()) {
|
||||||
|
obj.put("note", annotation.note)
|
||||||
|
}
|
||||||
|
|
||||||
val pointsArray = JSONArray()
|
val pointsArray = JSONArray()
|
||||||
annotation.points.forEach { p ->
|
annotation.points.forEach { p ->
|
||||||
|
|
@ -123,7 +130,10 @@ object AnnotationSerializer {
|
||||||
pageIndex = pageIndex,
|
pageIndex = pageIndex,
|
||||||
points = points,
|
points = points,
|
||||||
color = Color(colorInt),
|
color = Color(colorInt),
|
||||||
strokeWidth = strokeWidth
|
strokeWidth = strokeWidth,
|
||||||
|
id = obj.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
?: UUID.randomUUID().toString(),
|
||||||
|
note = obj.optString("note").takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!resultMap.containsKey(pageIndex)) {
|
if (!resultMap.containsKey(pageIndex)) {
|
||||||
|
|
@ -278,4 +288,4 @@ object HighlightSerializer {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ class PdfAnnotationRepository(private val context: Context) {
|
||||||
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
|
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
|
||||||
|
|
||||||
if (annotations.isEmpty()) {
|
if (annotations.isEmpty()) {
|
||||||
|
val file = getFile(bookId)
|
||||||
|
if (file.exists()) file.delete()
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,4 +83,4 @@ class PdfAnnotationRepository(private val context: Context) {
|
||||||
|
|
||||||
return if (valid) file else null
|
return if (valid) file else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,9 @@ interface PdfTextDao {
|
||||||
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
|
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
|
||||||
suspend fun getIndexedPageIndices(bookId: String): List<Int>
|
suspend fun getIndexedPageIndices(bookId: String): List<Int>
|
||||||
|
|
||||||
|
@Query("DELETE FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex")
|
||||||
|
suspend fun deletePageText(bookId: String, pageIndex: Int)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertPageText(entity: PdfSearchIndex)
|
suspend fun insertPageText(entity: PdfSearchIndex)
|
||||||
|
|
||||||
|
|
@ -158,4 +161,4 @@ abstract class PdfTextDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import com.aryan.reader.pdf.ReaderDocument
|
||||||
|
|
||||||
private const val TAG = "PdfSearchDiag"
|
private const val TAG = "PdfSearchDiag"
|
||||||
|
|
||||||
|
|
@ -55,6 +56,11 @@ class PdfTextRepository(context: Context) {
|
||||||
private val dao = db.pdfTextDao()
|
private val dao = db.pdfTextDao()
|
||||||
private val metaDao = db.pdfMetaDao()
|
private val metaDao = db.pdfMetaDao()
|
||||||
|
|
||||||
|
private suspend fun replacePageText(bookId: String, pageIndex: Int, content: String) {
|
||||||
|
dao.deletePageText(bookId, pageIndex)
|
||||||
|
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getPageRatios(bookId: String): List<Float>? {
|
suspend fun getPageRatios(bookId: String): List<Float>? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val meta = metaDao.getMetadata(bookId)
|
val meta = metaDao.getMetadata(bookId)
|
||||||
|
|
@ -250,7 +256,7 @@ class PdfTextRepository(context: Context) {
|
||||||
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
|
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
|
||||||
} else if (text.isNotBlank()) {
|
} else if (text.isNotBlank()) {
|
||||||
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
|
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
|
||||||
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text))
|
replacePageText(bookId = bookId, pageIndex = pageIndex, content = text)
|
||||||
} else {
|
} else {
|
||||||
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
|
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +285,69 @@ class PdfTextRepository(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun indexReaderPage(
|
||||||
|
bookId: String,
|
||||||
|
document: ReaderDocument,
|
||||||
|
pageIndex: Int,
|
||||||
|
onOcrModelDownloading: () -> Unit = {}
|
||||||
|
): Boolean {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
var text = ""
|
||||||
|
var ocrUsed = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.openPage(pageIndex)?.use { page ->
|
||||||
|
page.openTextPage().use { textPage ->
|
||||||
|
val count = textPage.textPageCountChars()
|
||||||
|
if (count > 0) {
|
||||||
|
text = textPage.textPageGetText(0, count).orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "ReaderDocument extraction failed for page $pageIndex")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.isBlank()) {
|
||||||
|
var bitmap: android.graphics.Bitmap? = null
|
||||||
|
try {
|
||||||
|
document.openPage(pageIndex)?.use { page ->
|
||||||
|
val targetWidth = 1080
|
||||||
|
val pageWidth = page.getPageWidthPoint()
|
||||||
|
val pageHeight = page.getPageHeightPoint()
|
||||||
|
if (pageWidth > 0 && pageHeight > 0) {
|
||||||
|
val aspectRatio = pageWidth.toFloat() / pageHeight.toFloat()
|
||||||
|
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||||
|
bitmap = createBitmap(targetWidth, targetHeight)
|
||||||
|
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
try {
|
||||||
|
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
|
||||||
|
text = visionText?.text.orEmpty()
|
||||||
|
ocrUsed = true
|
||||||
|
} finally {
|
||||||
|
it.recycle()
|
||||||
|
bitmap = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
bitmap?.recycle()
|
||||||
|
Timber.tag(TAG).e(e, "ReaderDocument OCR failed for page $pageIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cleaned = cleanIndexedText(text)
|
||||||
|
if (cleaned.isNotBlank()) {
|
||||||
|
replacePageText(bookId = bookId, pageIndex = pageIndex, content = cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrUsed && cleaned.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -599,4 +668,17 @@ class PdfTextRepository(context: Context) {
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cleanIndexedText(raw: String): String {
|
||||||
|
if (raw.isBlank()) return ""
|
||||||
|
var text = raw
|
||||||
|
val patterns = listOf(
|
||||||
|
Regex("(?i)file:/?/?/?\\S+"),
|
||||||
|
Regex("(?i)/data/user/\\d+/\\S+"),
|
||||||
|
Regex("(?i)/storage/emulated/\\d+/\\S+"),
|
||||||
|
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
|
||||||
|
)
|
||||||
|
patterns.forEach { pattern -> text = text.replace(pattern, " ") }
|
||||||
|
return text.replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2544
app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt
Normal file
2544
app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -83,7 +83,9 @@ private const val PREFETCH_LOOKAHEAD = 3
|
||||||
class TtsPlaybackManager(
|
class TtsPlaybackManager(
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
||||||
private val onResetContext: () -> Unit
|
private val onResetContext: () -> Unit,
|
||||||
|
private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> },
|
||||||
|
private val onPlaybackSessionStopped: () -> Unit = {}
|
||||||
) : MediaSession.Callback, Player.Listener {
|
) : MediaSession.Callback, Player.Listener {
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
@ -415,6 +417,8 @@ class TtsPlaybackManager(
|
||||||
handleStopTts(clearState = false)
|
handleStopTts(clearState = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPlaybackSessionPreparing(bookTitle, chapterTitle)
|
||||||
|
|
||||||
textChunks = chunks
|
textChunks = chunks
|
||||||
currentSpeakerId = speakerId
|
currentSpeakerId = speakerId
|
||||||
currentTtsMode = ttsMode
|
currentTtsMode = ttsMode
|
||||||
|
|
@ -550,6 +554,7 @@ class TtsPlaybackManager(
|
||||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||||
if (firstChunk == null) {
|
if (firstChunk == null) {
|
||||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
||||||
|
onPlaybackSessionStopped()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -627,6 +632,7 @@ class TtsPlaybackManager(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
||||||
)
|
)
|
||||||
|
onPlaybackSessionStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,6 +673,7 @@ class TtsPlaybackManager(
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
|
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
|
||||||
)
|
)
|
||||||
|
onPlaybackSessionStopped()
|
||||||
onResetContext()
|
onResetContext()
|
||||||
preparationJob?.cancel()
|
preparationJob?.cancel()
|
||||||
wordTrackingJob?.cancel()
|
wordTrackingJob?.cancel()
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@
|
||||||
package com.aryan.reader.tts
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
|
@ -30,13 +36,16 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
||||||
import com.aryan.reader.isByokCloudTtsAvailable
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
import com.aryan.reader.loadAiByokSettings
|
import com.aryan.reader.loadAiByokSettings
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -229,6 +238,11 @@ class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
|
||||||
|
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
|
||||||
|
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
|
||||||
|
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class TtsService : MediaSessionService() {
|
class TtsService : MediaSessionService() {
|
||||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
@ -237,6 +251,24 @@ class TtsService : MediaSessionService() {
|
||||||
private lateinit var playbackManager: TtsPlaybackManager
|
private lateinit var playbackManager: TtsPlaybackManager
|
||||||
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
|
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
|
||||||
private lateinit var cacheManager: TtsCacheManager
|
private lateinit var cacheManager: TtsCacheManager
|
||||||
|
private var foregroundNotificationShown = false
|
||||||
|
private var foregroundPlaybackExpected = false
|
||||||
|
private var foregroundIdleJob: Job? = null
|
||||||
|
private var foregroundBookTitle: String? = null
|
||||||
|
private var foregroundChapterTitle: String? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val result = super.onStartCommand(intent, flags, startId)
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
|
||||||
|
)
|
||||||
|
val hasPreparedMedia = ::player.isInitialized && player.mediaItemCount > 0
|
||||||
|
if (!hasPreparedMedia) {
|
||||||
|
showPreparingForegroundNotification("onStartCommand")
|
||||||
|
scheduleForegroundIdleStop(startId)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
|
@ -252,21 +284,133 @@ class TtsService : MediaSessionService() {
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||||
if (startInForegroundRequired) {
|
"POST_NOTIFICATIONS is missing, but MediaSession notifications are exempt. Delegating notification update."
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
)
|
||||||
stopSelf()
|
|
||||||
} else {
|
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||||
super.onUpdateNotification(session, startInForegroundRequired)
|
super.onUpdateNotification(session, startInForegroundRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showPreparingForegroundNotification(
|
||||||
|
reason: String,
|
||||||
|
bookTitle: String? = foregroundBookTitle,
|
||||||
|
chapterTitle: String? = foregroundChapterTitle
|
||||||
|
) {
|
||||||
|
foregroundBookTitle = bookTitle
|
||||||
|
foregroundChapterTitle = chapterTitle
|
||||||
|
ensureTtsNotificationChannel()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val notification = buildPreparingForegroundNotification(bookTitle, chapterTitle)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
TTS_FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(TTS_FOREGROUND_NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
foregroundNotificationShown = true
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Fallback foreground notification shown. reason=$reason")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "Failed to show fallback foreground notification. reason=$reason")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPreparingForegroundNotification(bookTitle: String?, chapterTitle: String?): Notification {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
}
|
||||||
|
val contentIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(this, 0, it, pendingIntentFlags())
|
||||||
|
}
|
||||||
|
val title = bookTitle?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name)
|
||||||
|
val text = chapterTitle?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { getString(R.string.tts_notification_preparing_chapter, it) }
|
||||||
|
?: getString(R.string.tts_notification_preparing)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, TTS_FOREGROUND_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.apply {
|
||||||
|
if (contentIntent != null) {
|
||||||
|
setContentIntent(contentIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureTtsNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
TTS_FOREGROUND_CHANNEL_ID,
|
||||||
|
getString(R.string.tts_notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.tts_notification_channel_desc)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentFlags(): Int {
|
||||||
|
return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleForegroundIdleStop(startId: Int) {
|
||||||
|
foregroundIdleJob?.cancel()
|
||||||
|
foregroundIdleJob = scope.launch {
|
||||||
|
delay(TTS_FOREGROUND_IDLE_GRACE_MS)
|
||||||
|
val playbackInactive = !::player.isInitialized ||
|
||||||
|
(!player.isPlaying && !player.playWhenReady && player.mediaItemCount == 0)
|
||||||
|
if (!foregroundPlaybackExpected && playbackInactive) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||||
|
"Foreground service start did not become an active TTS session. Stopping fallback foreground."
|
||||||
|
)
|
||||||
|
stopTtsForeground()
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPlaybackSessionPreparing(bookTitle: String?, chapterTitle: String?) {
|
||||||
|
foregroundPlaybackExpected = true
|
||||||
|
foregroundIdleJob?.cancel()
|
||||||
|
showPreparingForegroundNotification("START_TTS_COMMAND", bookTitle, chapterTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPlaybackSessionStopped() {
|
||||||
|
foregroundPlaybackExpected = false
|
||||||
|
foregroundIdleJob?.cancel()
|
||||||
|
foregroundBookTitle = null
|
||||||
|
foregroundChapterTitle = null
|
||||||
|
stopTtsForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopTtsForeground() {
|
||||||
|
if (!foregroundNotificationShown) return
|
||||||
|
try {
|
||||||
|
stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(e, "Failed to stop fallback foreground notification.")
|
||||||
|
} finally {
|
||||||
|
foregroundNotificationShown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val okHttpClient = OkHttpClient.Builder().build()
|
private val okHttpClient = OkHttpClient.Builder().build()
|
||||||
private val liveClient by lazy {
|
private val liveClient by lazy {
|
||||||
GeminiLiveClient(okHttpClient) { errorMsg ->
|
GeminiLiveClient(okHttpClient) { errorMsg ->
|
||||||
|
|
@ -668,7 +812,9 @@ class TtsService : MediaSessionService() {
|
||||||
playbackManager = TtsPlaybackManager(
|
playbackManager = TtsPlaybackManager(
|
||||||
player = player,
|
player = player,
|
||||||
generateAudioChunk = audioGenerator,
|
generateAudioChunk = audioGenerator,
|
||||||
onResetContext = { liveClient.close() }
|
onResetContext = { liveClient.close() },
|
||||||
|
onPlaybackSessionPreparing = ::onPlaybackSessionPreparing,
|
||||||
|
onPlaybackSessionStopped = ::onPlaybackSessionStopped
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaSession = MediaSession.Builder(this, player)
|
mediaSession = MediaSession.Builder(this, player)
|
||||||
|
|
@ -683,7 +829,7 @@ class TtsService : MediaSessionService() {
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
||||||
)
|
)
|
||||||
if (!player.playWhenReady) {
|
if (!::player.isInitialized || !player.playWhenReady) {
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
@ -700,13 +846,26 @@ class TtsService : MediaSessionService() {
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Timber.d("TtsService is being destroyed.")
|
Timber.d("TtsService is being destroyed.")
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||||
baseTtsSynthesizer.shutdown()
|
foregroundIdleJob?.cancel()
|
||||||
playbackManager.release()
|
stopTtsForeground()
|
||||||
mediaSession?.run {
|
if (::baseTtsSynthesizer.isInitialized) {
|
||||||
player.release()
|
baseTtsSynthesizer.shutdown()
|
||||||
release()
|
}
|
||||||
|
if (::playbackManager.isInitialized) {
|
||||||
|
playbackManager.release()
|
||||||
|
}
|
||||||
|
var playerReleased = false
|
||||||
|
mediaSession?.let { session ->
|
||||||
|
if (::player.isInitialized) {
|
||||||
|
player.release()
|
||||||
|
playerReleased = true
|
||||||
|
}
|
||||||
|
session.release()
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
}
|
}
|
||||||
|
if (!playerReleased && ::player.isInitialized) {
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -508,6 +508,7 @@
|
||||||
<string name="error_recent_item_not_found">Could not find recent item.</string>
|
<string name="error_recent_item_not_found">Could not find recent item.</string>
|
||||||
<string name="error_import_file_failed">Failed to import file.</string>
|
<string name="error_import_file_failed">Failed to import file.</string>
|
||||||
<string name="error_file_location_not_found">Could not find file location.</string>
|
<string name="error_file_location_not_found">Could not find file location.</string>
|
||||||
|
<string name="error_unsupported_file_type">This file type is not supported.</string>
|
||||||
<!-- "Text view" is the name of the PDF reflow feature in this app. -->
|
<!-- "Text view" is the name of the PDF reflow feature in this app. -->
|
||||||
<string name="error_load_generated_text_view">Failed to load generated text view.</string>
|
<string name="error_load_generated_text_view">Failed to load generated text view.</string>
|
||||||
<!-- "Text view" is the name of the PDF reflow feature in this app. -->
|
<!-- "Text view" is the name of the PDF reflow feature in this app. -->
|
||||||
|
|
@ -690,10 +691,10 @@
|
||||||
<string name="menu_view_original_pdf">View Original PDF</string>
|
<string name="menu_view_original_pdf">View Original PDF</string>
|
||||||
<!-- "Text View" is the name of the PDF reflow feature in this app — keep consistent. -->
|
<!-- "Text View" is the name of the PDF reflow feature in this app — keep consistent. -->
|
||||||
<string name="menu_delete_text_view">Delete Text View</string>
|
<string name="menu_delete_text_view">Delete Text View</string>
|
||||||
<!-- Menu item to switch reading mode. The colon-separated format "Reading Mode: Vertical" is intentional — do not omit the colon. -->
|
<!-- Menu item inside the reading mode submenu. -->
|
||||||
<string name="menu_reading_mode_vertical">Reading Mode: Vertical</string>
|
<string name="menu_reading_mode_vertical">Vertical</string>
|
||||||
<!-- Menu item to switch reading mode. The colon-separated format "Reading Mode: Paginated" is intentional — do not omit the colon. -->
|
<!-- Menu item inside the reading mode submenu. -->
|
||||||
<string name="menu_reading_mode_paginated">Reading Mode: Paginated</string>
|
<string name="menu_reading_mode_paginated">Paginated (left-to-right)</string>
|
||||||
<!-- Accessibility label for a menu item that is currently active/checked. -->
|
<!-- Accessibility label for a menu item that is currently active/checked. -->
|
||||||
<string name="content_desc_enabled">Enabled</string>
|
<string name="content_desc_enabled">Enabled</string>
|
||||||
<string name="menu_remove_bookmark">Remove bookmark</string>
|
<string name="menu_remove_bookmark">Remove bookmark</string>
|
||||||
|
|
@ -704,11 +705,17 @@
|
||||||
<string name="menu_realistic_page_turns">Realistic Page Turns</string>
|
<string name="menu_realistic_page_turns">Realistic Page Turns</string>
|
||||||
<string name="menu_keep_screen_on">Keep Screen On</string>
|
<string name="menu_keep_screen_on">Keep Screen On</string>
|
||||||
<string name="menu_visual_options">Visual Options</string>
|
<string name="menu_visual_options">Visual Options</string>
|
||||||
|
<string name="menu_screen_orientation">Screen Orientation</string>
|
||||||
|
<string name="menu_change_reading_mode">Change Reading Mode</string>
|
||||||
|
<string name="menu_right_to_left_pagination">Paginated (right-to-left)</string>
|
||||||
<string name="menu_auto_scroll">Auto Scroll</string>
|
<string name="menu_auto_scroll">Auto Scroll</string>
|
||||||
<!-- TTS = Text-to-Speech. -->
|
<!-- TTS = Text-to-Speech. -->
|
||||||
|
<string name="menu_tts_settings">TTS Settings</string>
|
||||||
|
<!-- TTS = Text-to-Speech. -->
|
||||||
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
|
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
|
||||||
<!-- TTS = Text-to-Speech. -->
|
<!-- TTS = Text-to-Speech. -->
|
||||||
<string name="menu_tts_word_replacements">TTS Word Replacements</string>
|
<string name="menu_tts_word_replacements">TTS Word Replacements</string>
|
||||||
|
<string name="menu_share_save_print">Share, Save or Print</string>
|
||||||
<!-- TTS = Text-to-Speech. Debug-only menu item. -->
|
<!-- TTS = Text-to-Speech. Debug-only menu item. -->
|
||||||
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
|
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
|
||||||
<string name="content_desc_navigate_slider">Navigate with slider</string>
|
<string name="content_desc_navigate_slider">Navigate with slider</string>
|
||||||
|
|
@ -824,9 +831,16 @@
|
||||||
<string name="button_import_from_files">Import from Files</string>
|
<string name="button_import_from_files">Import from Files</string>
|
||||||
<string name="no_imported_fonts_yet">No imported fonts yet.</string>
|
<string name="no_imported_fonts_yet">No imported fonts yet.</string>
|
||||||
<string name="visual_options_title">Visual Options</string>
|
<string name="visual_options_title">Visual Options</string>
|
||||||
|
<string name="visual_options_page_layout">Page layout</string>
|
||||||
|
<string name="visual_options_remove_page_gap">Remove gap between pages</string>
|
||||||
|
<string name="visual_options_remove_page_gap_desc">Applies to vertical reading mode.</string>
|
||||||
|
<string name="visual_options_hide_page_number_overlay">Hide page number overlay</string>
|
||||||
|
<string name="visual_options_hide_page_number_overlay_desc">Removes the small page count label from each page.</string>
|
||||||
<!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. -->
|
<!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. -->
|
||||||
<string name="visual_options_system_ui">System UI (Status & Navigation Bars)</string>
|
<string name="visual_options_system_ui">System UI (Status & Navigation Bars)</string>
|
||||||
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
|
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
|
||||||
|
<string name="visual_options_screen_orientation">Screen Orientation</string>
|
||||||
|
<string name="visual_options_screen_orientation_desc">Choose whether the reader follows the system orientation or prefers portrait or landscape when Android allows it.</string>
|
||||||
<string name="visual_options_progress_bar">Progress Bar</string>
|
<string name="visual_options_progress_bar">Progress Bar</string>
|
||||||
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
|
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
|
||||||
<string name="visual_options_progress_bar_position">Position</string>
|
<string name="visual_options_progress_bar_position">Position</string>
|
||||||
|
|
@ -1164,6 +1178,11 @@
|
||||||
<string name="section_files">Files</string>
|
<string name="section_files">Files</string>
|
||||||
|
|
||||||
<!-- TTS settings sheet labels. TTS = Text-to-Speech. -->
|
<!-- TTS settings sheet labels. TTS = Text-to-Speech. -->
|
||||||
|
<string name="tts_notification_channel_name">Text to speech</string>
|
||||||
|
<string name="tts_notification_channel_desc">Playback controls for text to speech.</string>
|
||||||
|
<string name="tts_notification_preparing">Preparing text to speech</string>
|
||||||
|
<!-- Notification text. %1$s = chapter title. -->
|
||||||
|
<string name="tts_notification_preparing_chapter">Preparing: %1$s</string>
|
||||||
<string name="tts_active_engine">Active TTS Engine</string>
|
<string name="tts_active_engine">Active TTS Engine</string>
|
||||||
<string name="tts_mode_cloud_ai">Cloud AI</string>
|
<string name="tts_mode_cloud_ai">Cloud AI</string>
|
||||||
<string name="tts_mode_device_native">Device Native</string>
|
<string name="tts_mode_device_native">Device Native</string>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ data class ProUpgradeState(
|
||||||
val creditProducts: List<ProductDetailsEntity> = emptyList(),
|
val creditProducts: List<ProductDetailsEntity> = emptyList(),
|
||||||
val hasValidPurchase: Boolean = false,
|
val hasValidPurchase: Boolean = false,
|
||||||
val activePurchases: List<PurchaseEntity> = emptyList(),
|
val activePurchases: List<PurchaseEntity> = emptyList(),
|
||||||
|
val hasAccountConflict: Boolean = false,
|
||||||
val billingClientReady: Boolean = false,
|
val billingClientReady: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isVerifying: Boolean = false
|
val isVerifying: Boolean = false
|
||||||
|
|
@ -35,7 +36,11 @@ class BillingClientWrapper(
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
fun launchPurchaseFlow(activity: Activity, productId: String = PRO_LIFETIME_PRODUCT_ID) {
|
fun launchPurchaseFlow(
|
||||||
|
activity: Activity,
|
||||||
|
productId: String = PRO_LIFETIME_PRODUCT_ID,
|
||||||
|
obfuscatedAccountId: String? = null
|
||||||
|
) {
|
||||||
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
|
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
|
||||||
}
|
}
|
||||||
fun consumePurchase(purchaseToken: String) {}
|
fun consumePurchase(purchaseToken: String) {}
|
||||||
|
|
@ -44,6 +49,14 @@ class BillingClientWrapper(
|
||||||
_proUpgradeState.value = _proUpgradeState.value.copy(error = null)
|
_proUpgradeState.value = _proUpgradeState.value.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun markAccountConflict() {
|
||||||
|
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = true, isVerifying = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAccountConflict() {
|
||||||
|
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = false)
|
||||||
|
}
|
||||||
|
|
||||||
fun clearVerificationState() {
|
fun clearVerificationState() {
|
||||||
_proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false)
|
_proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false)
|
||||||
}
|
}
|
||||||
|
|
@ -51,4 +64,4 @@ class BillingClientWrapper(
|
||||||
companion object {
|
companion object {
|
||||||
const val PRO_LIFETIME_PRODUCT_ID = "episteme_pro_lifetime"
|
const val PRO_LIFETIME_PRODUCT_ID = "episteme_pro_lifetime"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,17 @@ data class BookMetadata(
|
||||||
val lastModifiedTimestamp: Long = 0L,
|
val lastModifiedTimestamp: Long = 0L,
|
||||||
val bookmarksJson: String? = null,
|
val bookmarksJson: String? = null,
|
||||||
val hasAnnotations: Boolean = false,
|
val hasAnnotations: Boolean = false,
|
||||||
|
val fileContentModifiedTimestamp: Long = 0L,
|
||||||
val customName: String? = null,
|
val customName: String? = null,
|
||||||
val highlightsJson: String? = null
|
val highlightsJson: String? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val originalTitle: String? = null,
|
||||||
|
val originalAuthor: String? = null,
|
||||||
|
val originalSeriesName: String? = null,
|
||||||
|
val originalSeriesIndex: Double? = null,
|
||||||
|
val originalDescription: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DeviceItem(
|
data class DeviceItem(
|
||||||
|
|
@ -135,4 +144,4 @@ class FirestoreRepository {
|
||||||
|
|
||||||
suspend fun addMessageToThread(threadId: String, messageId: String, uid: String, text: String, sender: String, attachments: List<String>) {}
|
suspend fun addMessageToThread(threadId: String, messageId: String, uid: String, text: String, sender: String, attachments: List<String>) {}
|
||||||
suspend fun markThreadAsRead(threadId: String) {}
|
suspend fun markThreadAsRead(threadId: String) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Episteme (OSS)</string>
|
<string name="app_name">Episteme oss</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.SharedSettingsAction
|
||||||
|
import com.aryan.reader.shared.SharedSettingsDestination
|
||||||
|
import com.aryan.reader.shared.SharedSettingsHubModel
|
||||||
|
import com.aryan.reader.shared.SharedSettingsItemModel
|
||||||
|
import com.aryan.reader.shared.sharedSettingsHubModel
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AndroidSettingsHubModelsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline android settings hide network backed sections`() {
|
||||||
|
val model = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(),
|
||||||
|
isOssBuild = true,
|
||||||
|
isOfflineBuild = true,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val actions = model.visibleNestedActions()
|
||||||
|
|
||||||
|
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
|
||||||
|
assertFalse(SharedSettingsAction.HIDE_READER_AI in actions)
|
||||||
|
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||||
|
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||||
|
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||||
|
assertFalse(SharedSettingsAction.HELP_FEEDBACK in actions)
|
||||||
|
assertFalse(SharedSettingsAction.SUPPORT in actions)
|
||||||
|
assertTrue(SharedSettingsAction.TTS_SETTINGS in actions)
|
||||||
|
assertTrue(SharedSettingsAction.CUSTOM_FONTS in actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oss online settings hide sync rows but keep oss ai key settings`() {
|
||||||
|
val model = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(
|
||||||
|
currentUser = UserData(
|
||||||
|
uid = "user-id",
|
||||||
|
displayName = "Reader",
|
||||||
|
photoUrl = null,
|
||||||
|
email = "reader@example.com"
|
||||||
|
),
|
||||||
|
isProUser = true,
|
||||||
|
isSyncEnabled = true,
|
||||||
|
isFolderSyncEnabled = true
|
||||||
|
),
|
||||||
|
isOssBuild = true,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val actions = model.visibleNestedActions()
|
||||||
|
|
||||||
|
assertTrue(SharedSettingsAction.AI_SETTINGS in actions)
|
||||||
|
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
|
||||||
|
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
|
||||||
|
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||||
|
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||||
|
assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions)
|
||||||
|
assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions)
|
||||||
|
assertTrue(SharedSettingsAction.SUPPORT in actions)
|
||||||
|
assertEquals(
|
||||||
|
"TTS & AI",
|
||||||
|
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non oss settings do not expose oss ai key settings`() {
|
||||||
|
val model = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(isProUser = true),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val actions = model.visibleNestedActions()
|
||||||
|
|
||||||
|
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
|
||||||
|
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
|
||||||
|
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||||
|
assertFalse(SharedSettingsAction.SUPPORT in actions)
|
||||||
|
assertEquals(
|
||||||
|
"TTS",
|
||||||
|
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `android settings expose debug-only storage actions only in debug`() {
|
||||||
|
val releaseActions = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
).visibleNestedActions()
|
||||||
|
val debugActions = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = true
|
||||||
|
)
|
||||||
|
).visibleNestedActions()
|
||||||
|
|
||||||
|
assertFalse(SharedSettingsAction.EXPORT_LOGS in releaseActions)
|
||||||
|
assertTrue(SharedSettingsAction.EXPORT_LOGS in debugActions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `android settings reflect global toggles from reader state`() {
|
||||||
|
val model = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(
|
||||||
|
isTabsEnabled = true,
|
||||||
|
useStrictFileFilter = true,
|
||||||
|
isScreenCaptureProtectionEnabled = true
|
||||||
|
),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val toggles = model.visibleNestedItems().associateBy { it.action }
|
||||||
|
|
||||||
|
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
|
||||||
|
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
|
||||||
|
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `android extra settings expose home overflow actions without settings duplicate`() {
|
||||||
|
val model = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val extraActions = model.page(SharedSettingsDestination.EXTRA).items.map { it.action }
|
||||||
|
|
||||||
|
assertTrue(SharedSettingsAction.TABS_TOGGLE in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION in extraActions)
|
||||||
|
assertTrue(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in extraActions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud sync row is gated by pro state`() {
|
||||||
|
val freeSync = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(isProUser = false),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
|
||||||
|
val proSync = sharedSettingsHubModel(
|
||||||
|
androidSettingsHubInput(
|
||||||
|
uiState = ReaderScreenState(isProUser = true),
|
||||||
|
isOssBuild = false,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isDebugBuild = false
|
||||||
|
)
|
||||||
|
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
|
||||||
|
|
||||||
|
assertFalse(freeSync.enabled)
|
||||||
|
assertTrue(proSync.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSettingsHubModel.visibleNestedItems(): List<SharedSettingsItemModel> {
|
||||||
|
return rootCategories.flatMap { category ->
|
||||||
|
page(category.destination).items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSettingsHubModel.visibleNestedActions(): List<SharedSettingsAction> {
|
||||||
|
return visibleNestedItems().map { it.action }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||||
|
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
|
||||||
|
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AndroidSharedStateBridgeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prepareLibraryProjection builds shared input and Android lookup context`() {
|
||||||
|
val tag = tag("tag", "Favorite")
|
||||||
|
val book = recentFile("book", sourceFolderUri = "content://folder")
|
||||||
|
val reflowCopy = recentFile("book_reflow", sourceFolderUri = "content://folder")
|
||||||
|
|
||||||
|
val context = AndroidSharedStateBridge.prepareLibraryProjection(
|
||||||
|
input = LibraryProjectionInput(
|
||||||
|
state = ReaderScreenState(),
|
||||||
|
recentFilesFromDb = listOf(book, reflowCopy),
|
||||||
|
dbShelves = emptyList(),
|
||||||
|
shelfRefs = emptyList(),
|
||||||
|
dbTags = listOf(tag),
|
||||||
|
tagRefs = listOf(BookTagCrossRef(bookId = book.bookId, tagId = tag.id))
|
||||||
|
),
|
||||||
|
folderPathResolver = EmptyFolderPathResolver
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("book"), context.androidBooksById.keys.toList())
|
||||||
|
assertEquals(listOf("book"), context.sharedInput.booksFromStore.map { it.id })
|
||||||
|
assertEquals(listOf("tag"), context.sharedInput.booksFromStore.single().tags.map { it.id })
|
||||||
|
assertEquals(listOf(AndroidSharedFolderProjectionKey("content://folder", "Local Folder")), context.folderKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reduceLibraryAction applies shared library state back to Android fields`() {
|
||||||
|
val book = recentFile("book")
|
||||||
|
val filters = LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)
|
||||||
|
|
||||||
|
val selected = AndroidSharedStateBridge.reduceLibraryAction(
|
||||||
|
current = ReaderScreenState(),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
|
||||||
|
action = SharedLibraryAction.BookSelectionToggled(book.bookId)
|
||||||
|
)
|
||||||
|
val filtered = AndroidSharedStateBridge.reduceLibraryAction(
|
||||||
|
current = selected,
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
|
||||||
|
action = SharedLibraryAction.FiltersChanged(filters.toSharedLibraryFilters())
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf(book), selected.contextualActionItems)
|
||||||
|
assertEquals(filters, filtered.libraryFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reduceLibraryAction drops selection ids that are not in projected Android books`() {
|
||||||
|
val result = AndroidSharedStateBridge.reduceLibraryAction(
|
||||||
|
current = ReaderScreenState(),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(recentFile("book"))),
|
||||||
|
action = SharedLibraryAction.BookSelectionToggled("missing")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.contextualActionItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reduceAppAction applies shared app state back to Android fields`() {
|
||||||
|
val result = AndroidSharedStateBridge.reduceAppAction(
|
||||||
|
current = ReaderScreenState(appThemeMode = AppThemeMode.LIGHT),
|
||||||
|
projectedState = ReaderScreenState(),
|
||||||
|
action = SharedAppAction.AppThemeChanged(SharedAppThemeMode.DARK)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(AppThemeMode.DARK, result.appThemeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
|
||||||
|
val result = AndroidSharedStateBridge.setTabsEnabled(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
isTabsEnabled = true,
|
||||||
|
openTabIds = listOf("one", "two"),
|
||||||
|
activeTabBookId = "two"
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState(),
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(false, result.isTabsEnabled)
|
||||||
|
assertEquals(listOf("two"), result.openTabIds)
|
||||||
|
assertEquals("two", result.activeTabBookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openBookTab delegates tab ordering and activation to shared reducer`() {
|
||||||
|
val result = AndroidSharedStateBridge.openBookTab(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
isTabsEnabled = false,
|
||||||
|
openTabIds = listOf("old"),
|
||||||
|
activeTabBookId = "old"
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState(),
|
||||||
|
bookId = "new"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, result.isTabsEnabled)
|
||||||
|
assertEquals(listOf("old", "new"), result.openTabIds)
|
||||||
|
assertEquals("new", result.activeTabBookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `closeBookTab selects the previous tab when the active tab closes`() {
|
||||||
|
val result = AndroidSharedStateBridge.closeBookTab(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
isTabsEnabled = true,
|
||||||
|
openTabIds = listOf("one", "two", "three"),
|
||||||
|
activeTabBookId = "three"
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState(),
|
||||||
|
bookId = "three"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, result.isTabsEnabled)
|
||||||
|
assertEquals(listOf("one", "two"), result.openTabIds)
|
||||||
|
assertEquals("two", result.activeTabBookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `closeAllTabs clears Android tab ids through shared reducer`() {
|
||||||
|
val result = AndroidSharedStateBridge.closeAllTabs(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
isTabsEnabled = true,
|
||||||
|
openTabIds = listOf("one", "two"),
|
||||||
|
activeTabBookId = "two"
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, result.isTabsEnabled)
|
||||||
|
assertTrue(result.openTabIds.isEmpty())
|
||||||
|
assertEquals(null, result.activeTabBookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `togglePinsForSelectedBooks pins mixed home selection and clears selection`() {
|
||||||
|
val pinned = recentFile("pinned")
|
||||||
|
val unpinned = recentFile("unpinned")
|
||||||
|
|
||||||
|
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
rawLibraryFiles = listOf(pinned, unpinned),
|
||||||
|
contextualActionItems = setOf(pinned, unpinned),
|
||||||
|
pinnedHomeBookIds = setOf(pinned.bookId)
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(pinned, unpinned)),
|
||||||
|
isHome = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf("pinned", "unpinned"), result.pinnedHomeBookIds)
|
||||||
|
assertTrue(result.contextualActionItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `togglePinsForSelectedBooks unpins when all selected library books are pinned`() {
|
||||||
|
val first = recentFile("first")
|
||||||
|
val second = recentFile("second")
|
||||||
|
|
||||||
|
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
|
||||||
|
current = ReaderScreenState(
|
||||||
|
rawLibraryFiles = listOf(first, second),
|
||||||
|
contextualActionItems = setOf(first, second),
|
||||||
|
pinnedLibraryBookIds = setOf(first.bookId, second.bookId)
|
||||||
|
),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(first, second)),
|
||||||
|
isHome = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.pinnedLibraryBookIds.isEmpty())
|
||||||
|
assertTrue(result.contextualActionItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaceBookSelectionWithVisibleBooks selects visible books through shared reducer`() {
|
||||||
|
val visible = recentFile("visible")
|
||||||
|
val hidden = recentFile("hidden")
|
||||||
|
|
||||||
|
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
|
||||||
|
current = ReaderScreenState(),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible, hidden)),
|
||||||
|
visibleBooks = listOf(visible)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf(visible), result.contextualActionItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaceBookSelectionWithVisibleBooks clears when visible books are already selected`() {
|
||||||
|
val visible = recentFile("visible")
|
||||||
|
|
||||||
|
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
|
||||||
|
current = ReaderScreenState(contextualActionItems = setOf(visible)),
|
||||||
|
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible)),
|
||||||
|
visibleBooks = listOf(visible)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.contextualActionItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recentFile(
|
||||||
|
id: String,
|
||||||
|
sourceFolderUri: String? = null
|
||||||
|
) = RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = "content://$id",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "$id.epub",
|
||||||
|
timestamp = 1L,
|
||||||
|
sourceFolderUri = sourceFolderUri
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun tag(id: String, name: String) = TagEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
createdAt = 1L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class EmbeddedEbookMetadataExtractorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `epub extracts text metadata and explicitly referenced cover image`() {
|
||||||
|
val coverBytes = onePixelPngBytes()
|
||||||
|
val epubBytes = zipBytes(
|
||||||
|
"META-INF/container.xml" to """
|
||||||
|
<container>
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/content.opf"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
"OEBPS/content.opf" to """
|
||||||
|
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<metadata>
|
||||||
|
<dc:title>Folder EPUB</dc:title>
|
||||||
|
<dc:creator>Octavia Butler</dc:creator>
|
||||||
|
<dc:description><p>Folder summary</p></dc:description>
|
||||||
|
<meta content="Patternist" name="calibre:series"/>
|
||||||
|
<meta content="3" name="calibre:series_index"/>
|
||||||
|
<meta name="cover" content="cover-image"/>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="cover-image" href="images/cover.png" media-type="image/png"/>
|
||||||
|
</manifest>
|
||||||
|
</package>
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
"OEBPS/images/cover.png" to coverBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
val metadata = EmbeddedEbookMetadataExtractor.extract(
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "folder.epub",
|
||||||
|
openStream = { ByteArrayInputStream(epubBytes) }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Folder EPUB", metadata.title)
|
||||||
|
assertEquals("Octavia Butler", metadata.author)
|
||||||
|
assertEquals("<p>Folder summary</p>", metadata.description)
|
||||||
|
assertEquals("Patternist", metadata.seriesName)
|
||||||
|
assertEquals(3.0, metadata.seriesIndex)
|
||||||
|
val cover = metadata.cover
|
||||||
|
assertNotNull(cover)
|
||||||
|
assertEquals("png", cover!!.extension)
|
||||||
|
assertArrayEquals(coverBytes, cover.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fb2 extracts coverpage binary without parsing book body`() {
|
||||||
|
val coverBytes = onePixelPngBytes()
|
||||||
|
val fb2 = """
|
||||||
|
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<book-title>Folder FB2</book-title>
|
||||||
|
<author>
|
||||||
|
<first-name>Ursula</first-name>
|
||||||
|
<last-name>Le Guin</last-name>
|
||||||
|
</author>
|
||||||
|
<annotation><image l:href="#not-cover.png"/></annotation>
|
||||||
|
<coverpage><image l:href="#cover.png"/></coverpage>
|
||||||
|
</title-info>
|
||||||
|
</description>
|
||||||
|
<body><section><p>Body text should not matter.</p></section></body>
|
||||||
|
<binary id="not-cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(ByteArray(0))}</binary>
|
||||||
|
<binary id="cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(coverBytes)}</binary>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val metadata = EmbeddedEbookMetadataExtractor.extract(
|
||||||
|
type = FileType.FB2,
|
||||||
|
displayName = "folder.fb2",
|
||||||
|
openStream = { ByteArrayInputStream(fb2.toByteArray(Charsets.UTF_8)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Folder FB2", metadata.title)
|
||||||
|
assertEquals("Ursula Le Guin", metadata.author)
|
||||||
|
val cover = metadata.cover
|
||||||
|
assertNotNull(cover)
|
||||||
|
assertEquals("png", cover!!.extension)
|
||||||
|
assertArrayEquals(coverBytes, cover.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mobi extracts EXTH text metadata and embedded cover record`() {
|
||||||
|
val coverBytes = onePixelPngBytes()
|
||||||
|
val mobiBytes = minimalMobiBytes(
|
||||||
|
title = "Folder MOBI",
|
||||||
|
author = "N K Jemisin",
|
||||||
|
coverBytes = coverBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
val metadata = EmbeddedEbookMetadataExtractor.extract(
|
||||||
|
type = FileType.MOBI,
|
||||||
|
displayName = "folder.mobi",
|
||||||
|
openStream = { ByteArrayInputStream(mobiBytes) }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Folder MOBI", metadata.title)
|
||||||
|
assertEquals("N K Jemisin", metadata.author)
|
||||||
|
val cover = metadata.cover
|
||||||
|
assertNotNull(cover)
|
||||||
|
assertEquals("png", cover!!.extension)
|
||||||
|
assertArrayEquals(coverBytes, cover.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
ZipOutputStream(out).use { zip ->
|
||||||
|
entries.forEach { (name, content) ->
|
||||||
|
zip.putNextEntry(ZipEntry(name))
|
||||||
|
zip.write(content)
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minimalMobiBytes(title: String, author: String, coverBytes: ByteArray): ByteArray {
|
||||||
|
val exthRecords = listOf(
|
||||||
|
exthStringRecord(99, title),
|
||||||
|
exthStringRecord(100, author),
|
||||||
|
exthIntRecord(201, 0)
|
||||||
|
)
|
||||||
|
val exthSize = 12 + exthRecords.sumOf { it.size }
|
||||||
|
val mobiHeaderLength = 232
|
||||||
|
val record0 = ByteArray(16 + mobiHeaderLength + exthSize)
|
||||||
|
putU16(record0, 0, 1)
|
||||||
|
putU32(record0, 4, 0)
|
||||||
|
putU16(record0, 8, 0)
|
||||||
|
putU16(record0, 12, 0)
|
||||||
|
putAscii(record0, 16, "MOBI")
|
||||||
|
putU32(record0, 20, mobiHeaderLength)
|
||||||
|
putU32(record0, 16 + 12, 65001)
|
||||||
|
putU32(record0, 16 + 68, 0)
|
||||||
|
putU32(record0, 16 + 72, 0)
|
||||||
|
putU32(record0, 16 + 92, 1)
|
||||||
|
|
||||||
|
val exthOffset = 16 + mobiHeaderLength
|
||||||
|
putAscii(record0, exthOffset, "EXTH")
|
||||||
|
putU32(record0, exthOffset + 4, exthSize)
|
||||||
|
putU32(record0, exthOffset + 8, exthRecords.size)
|
||||||
|
var cursor = exthOffset + 12
|
||||||
|
exthRecords.forEach { record ->
|
||||||
|
record.copyInto(record0, cursor)
|
||||||
|
cursor += record.size
|
||||||
|
}
|
||||||
|
|
||||||
|
val palmHeader = ByteArray(78 + 8 * 2)
|
||||||
|
putU16(palmHeader, 76, 2)
|
||||||
|
val record0Offset = palmHeader.size
|
||||||
|
val coverOffset = record0Offset + record0.size
|
||||||
|
putU32(palmHeader, 78, record0Offset)
|
||||||
|
putU32(palmHeader, 86, coverOffset)
|
||||||
|
|
||||||
|
return palmHeader + record0 + coverBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exthStringRecord(type: Int, value: String): ByteArray {
|
||||||
|
val data = value.toByteArray(Charsets.UTF_8)
|
||||||
|
return exthRecord(type, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exthIntRecord(type: Int, value: Int): ByteArray {
|
||||||
|
val data = ByteArray(4)
|
||||||
|
putU32(data, 0, value)
|
||||||
|
return exthRecord(type, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exthRecord(type: Int, data: ByteArray): ByteArray {
|
||||||
|
val record = ByteArray(8 + data.size)
|
||||||
|
putU32(record, 0, type)
|
||||||
|
putU32(record, 4, record.size)
|
||||||
|
data.copyInto(record, 8)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putAscii(target: ByteArray, offset: Int, value: String) {
|
||||||
|
value.toByteArray(Charsets.US_ASCII).copyInto(target, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putU16(target: ByteArray, offset: Int, value: Int) {
|
||||||
|
target[offset] = ((value ushr 8) and 0xFF).toByte()
|
||||||
|
target[offset + 1] = (value and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putU32(target: ByteArray, offset: Int, value: Int) {
|
||||||
|
target[offset] = ((value ushr 24) and 0xFF).toByte()
|
||||||
|
target[offset + 1] = ((value ushr 16) and 0xFF).toByte()
|
||||||
|
target[offset + 2] = ((value ushr 8) and 0xFF).toByte()
|
||||||
|
target[offset + 3] = (value and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onePixelPngBytes(): ByteArray {
|
||||||
|
return Base64.getDecoder().decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.aryan.reader
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -20,6 +21,21 @@ class FileTypeResolverTest {
|
||||||
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
|
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
|
||||||
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
|
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
|
||||||
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
|
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
|
||||||
|
assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `metadata resolver maps provider mime types without exposing generic archives`() {
|
||||||
|
assertEquals(FileType.PDF, resolveFileTypeFromMetadata("download", "application/pdf"))
|
||||||
|
assertEquals(FileType.DOCX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||||
|
assertEquals(FileType.PPTX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.presentationml.presentation"))
|
||||||
|
assertEquals(FileType.MD, resolveFileTypeFromMetadata("notes.markdown.txt", "text/plain; charset=utf-8"))
|
||||||
|
assertEquals(FileType.EPUB, resolveFileTypeFromMetadata("book.epub.txt", "text/plain"))
|
||||||
|
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
|
||||||
|
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
|
||||||
|
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
|
||||||
|
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
|
||||||
|
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -39,7 +55,9 @@ class FileTypeResolverTest {
|
||||||
@Test
|
@Test
|
||||||
fun `plain txt remains txt when inner extension is unsupported`() {
|
fun `plain txt remains txt when inner extension is unsupported`() {
|
||||||
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
||||||
|
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
|
||||||
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
|
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
|
||||||
|
assertNull(resolveFileTypeFromName("archive.zip"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ class LibraryStateProjectorTest {
|
||||||
assertEquals(files.ids(), filterBySearch(files, " ").ids())
|
assertEquals(files.ids(), filterBySearch(files, " ").ids())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterBySearch preserves android display-name matching when a custom name exists`() {
|
||||||
|
val file = recentFile("custom", displayName = "Original File.pdf", customName = "Renamed")
|
||||||
|
|
||||||
|
assertEquals(listOf("custom"), filterBySearch(listOf(file), "original").ids())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `applyLibraryFilters requires all active filters to match`() {
|
fun `applyLibraryFilters requires all active filters to match`() {
|
||||||
val activeTag = tag("active", "Active")
|
val activeTag = tag("active", "Active")
|
||||||
|
|
@ -188,6 +195,7 @@ class LibraryStateProjectorTest {
|
||||||
sortOrder = SortOrder.TITLE_ASC,
|
sortOrder = SortOrder.TITLE_ASC,
|
||||||
recentFilesLimit = 1,
|
recentFilesLimit = 1,
|
||||||
openTabIds = listOf("beta", "missing"),
|
openTabIds = listOf("beta", "missing"),
|
||||||
|
activeTabBookId = "missing",
|
||||||
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
|
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
|
||||||
viewingShelfId = "manual",
|
viewingShelfId = "manual",
|
||||||
contextualActionShelfIds = setOf("manual", "missing")
|
contextualActionShelfIds = setOf("manual", "missing")
|
||||||
|
|
@ -208,6 +216,8 @@ class LibraryStateProjectorTest {
|
||||||
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
|
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
|
||||||
assertEquals(listOf("beta"), result.recentFiles.ids())
|
assertEquals(listOf("beta"), result.recentFiles.ids())
|
||||||
assertEquals(listOf("beta"), result.openTabs.ids())
|
assertEquals(listOf("beta"), result.openTabs.ids())
|
||||||
|
assertEquals(listOf("beta"), result.openTabIds)
|
||||||
|
assertNull(result.activeTabBookId)
|
||||||
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
|
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
|
||||||
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
|
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
|
||||||
assertEquals("manual", result.viewingShelfId)
|
assertEquals("manual", result.viewingShelfId)
|
||||||
|
|
@ -266,6 +276,30 @@ class LibraryStateProjectorTest {
|
||||||
assertEquals(listOf(tag), result.allTags)
|
assertEquals(listOf(tag), result.allTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `project keeps pinned home and library books first using shared projector`() {
|
||||||
|
val older = recentFile("older", title = "Zulu", timestamp = 1L)
|
||||||
|
val newer = recentFile("newer", title = "Alpha", timestamp = 2L)
|
||||||
|
|
||||||
|
val result = LibraryStateProjector().project(
|
||||||
|
LibraryProjectionInput(
|
||||||
|
state = ReaderScreenState(
|
||||||
|
sortOrder = SortOrder.TITLE_ASC,
|
||||||
|
pinnedHomeBookIds = setOf("older"),
|
||||||
|
pinnedLibraryBookIds = setOf("older")
|
||||||
|
),
|
||||||
|
recentFilesFromDb = listOf(older, newer),
|
||||||
|
dbShelves = emptyList(),
|
||||||
|
shelfRefs = emptyList(),
|
||||||
|
dbTags = emptyList(),
|
||||||
|
tagRefs = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("older", "newer"), result.recentFiles.ids())
|
||||||
|
assertEquals(listOf("older", "newer"), result.allRecentFiles.ids())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `project builds manual tag series and unshelved shelves`() {
|
fun `project builds manual tag series and unshelved shelves`() {
|
||||||
val favorite = tag("favorite", "Favorite")
|
val favorite = tag("favorite", "Favorite")
|
||||||
|
|
@ -479,7 +513,8 @@ class LibraryStateProjectorTest {
|
||||||
tags: List<TagEntity> = emptyList(),
|
tags: List<TagEntity> = emptyList(),
|
||||||
fileSize: Long = 0L,
|
fileSize: Long = 0L,
|
||||||
seriesName: String? = null,
|
seriesName: String? = null,
|
||||||
seriesIndex: Double? = null
|
seriesIndex: Double? = null,
|
||||||
|
customName: String? = null
|
||||||
) = RecentFileItem(
|
) = RecentFileItem(
|
||||||
bookId = id,
|
bookId = id,
|
||||||
uriString = uriString,
|
uriString = uriString,
|
||||||
|
|
@ -494,7 +529,8 @@ class LibraryStateProjectorTest {
|
||||||
tags = tags,
|
tags = tags,
|
||||||
fileSize = fileSize,
|
fileSize = fileSize,
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex
|
seriesIndex = seriesIndex,
|
||||||
|
customName = customName
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun tag(id: String, name: String) = TagEntity(
|
private fun tag(id: String, name: String) = TagEntity(
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,12 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.credentials.CredentialManager
|
import androidx.credentials.CredentialManager
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.android.billingclient.api.BillingClient
|
|
||||||
import com.android.billingclient.api.BillingResult
|
|
||||||
import com.aryan.reader.data.*
|
import com.aryan.reader.data.*
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||||
import com.aryan.reader.tts.TtsController
|
import com.aryan.reader.tts.TtsController
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -108,28 +104,8 @@ class MainViewModelTest {
|
||||||
mockkObject(WorkManager.Companion)
|
mockkObject(WorkManager.Companion)
|
||||||
val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
every { WorkManager.getInstance(any()) } returns mockWorkManager
|
every { WorkManager.getInstance(any()) } returns mockWorkManager
|
||||||
mockkStatic(FirebaseAuth::class)
|
|
||||||
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
|
|
||||||
mockkStatic(FirebaseFirestore::class)
|
|
||||||
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
|
|
||||||
mockkObject(CredentialManager.Companion)
|
mockkObject(CredentialManager.Companion)
|
||||||
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
|
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
|
||||||
mockkStatic(BillingClient::class)
|
|
||||||
val mockBillingClient = mockk<BillingClient>(relaxed = true)
|
|
||||||
val mockBillingBuilder = mockk<BillingClient.Builder>(relaxed = true)
|
|
||||||
every { BillingClient.newBuilder(any()) } returns mockBillingBuilder
|
|
||||||
every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder
|
|
||||||
every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder
|
|
||||||
every { mockBillingBuilder.build() } returns mockBillingClient
|
|
||||||
every { mockBillingClient.isReady } returns false
|
|
||||||
every { mockBillingClient.startConnection(any()) } answers {
|
|
||||||
firstArg<com.android.billingclient.api.BillingClientStateListener>()
|
|
||||||
.onBillingSetupFinished(
|
|
||||||
BillingResult.newBuilder()
|
|
||||||
.setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mockkConstructor(AuthRepository::class)
|
mockkConstructor(AuthRepository::class)
|
||||||
mockkConstructor(RecentFilesRepository::class)
|
mockkConstructor(RecentFilesRepository::class)
|
||||||
mockkConstructor(BillingClientWrapper::class)
|
mockkConstructor(BillingClientWrapper::class)
|
||||||
|
|
@ -140,6 +116,14 @@ class MainViewModelTest {
|
||||||
mockkConstructor(TtsController::class)
|
mockkConstructor(TtsController::class)
|
||||||
|
|
||||||
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
|
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
|
||||||
|
every { anyConstructed<BillingClientWrapper>().initializeConnection() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().refreshPurchasesAsync() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().clearVerificationState() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().clearAccountConflict() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().markAccountConflict() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().clearError() } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().consumePurchase(any()) } just Runs
|
||||||
|
every { anyConstructed<BillingClientWrapper>().launchPurchaseFlow(any(), any(), any()) } just Runs
|
||||||
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
|
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
|
||||||
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
|
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
|
||||||
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
|
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
|
||||||
|
|
@ -566,6 +550,36 @@ class MainViewModelTest {
|
||||||
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
|
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.uiState.collect {}
|
||||||
|
}
|
||||||
|
val requested = LibraryFilters(
|
||||||
|
fileTypes = setOf(FileType.PDF, FileType.UNKNOWN),
|
||||||
|
sourceFolders = setOf("content://sync"),
|
||||||
|
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||||
|
tagIds = setOf("favorite")
|
||||||
|
)
|
||||||
|
val expected = requested.copy(fileTypes = setOf(FileType.PDF))
|
||||||
|
|
||||||
|
viewModel.updateLibraryFilters(requested)
|
||||||
|
|
||||||
|
val state = viewModel.uiState.first { it.libraryFilters == expected }
|
||||||
|
assertEquals(expected, state.libraryFilters)
|
||||||
|
assertFalse(FileType.UNKNOWN in state.libraryFilters.fileTypes)
|
||||||
|
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saved library file filters drop stale unknown values during restore`() = runTest {
|
||||||
|
every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN")
|
||||||
|
|
||||||
|
val restored = MainViewModel(mockApplication)
|
||||||
|
|
||||||
|
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
|
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
|
||||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
|
@ -954,6 +968,22 @@ class MainViewModelTest {
|
||||||
assertEquals(null, clearedState.bannerMessage)
|
assertEquals(null, clearedState.bannerMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persistent banner is not auto dismissed`() = runTest {
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.uiState.collect {}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.showBanner("Syncing", isPersistent = true)
|
||||||
|
viewModel.uiState.first { it.bannerMessage?.message == "Syncing" }
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
advanceTimeBy(3_000L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals("Syncing", viewModel.uiState.value.bannerMessage?.message)
|
||||||
|
}
|
||||||
|
|
||||||
private fun recentFile(
|
private fun recentFile(
|
||||||
id: String,
|
id: String,
|
||||||
type: FileType = FileType.EPUB,
|
type: FileType = FileType.EPUB,
|
||||||
|
|
@ -970,10 +1000,16 @@ class MainViewModelTest {
|
||||||
title = title
|
title = title
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun mockUri(uriString: String): Uri {
|
private fun mockUri(
|
||||||
|
uriString: String,
|
||||||
|
path: String? = uriString.substringAfter(":", ""),
|
||||||
|
lastPathSegment: String? = path?.substringAfterLast('/')
|
||||||
|
): Uri {
|
||||||
return mockk<Uri>().also { uri ->
|
return mockk<Uri>().also { uri ->
|
||||||
every { uri.toString() } returns uriString
|
every { uri.toString() } returns uriString
|
||||||
every { uri.scheme } returns uriString.substringBefore(":", "")
|
every { uri.scheme } returns uriString.substringBefore(":", "")
|
||||||
|
every { uri.path } returns path
|
||||||
|
every { uri.lastPathSegment } returns lastPathSegment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
package com.aryan.reader
|
|
||||||
|
|
||||||
import com.aryan.reader.data.RecentFileItem
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class NonReaderScreenModelsTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `home model treats open tabs as non-empty content`() {
|
|
||||||
val tab = recentFile("tab")
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
isTabsEnabled = true,
|
|
||||||
openTabs = listOf(tab),
|
|
||||||
rawLibraryFiles = listOf(tab)
|
|
||||||
).toHomeScreenModel()
|
|
||||||
|
|
||||||
assertFalse(model.isEmpty)
|
|
||||||
assertTrue(model.isLibraryEmpty)
|
|
||||||
assertEquals(listOf(tab), model.openTabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `home model reports empty when there are no recents or open tabs`() {
|
|
||||||
val archivedBook = recentFile("archived", isRecent = false)
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
recentFiles = emptyList(),
|
|
||||||
rawLibraryFiles = listOf(archivedBook)
|
|
||||||
).toHomeScreenModel()
|
|
||||||
|
|
||||||
assertTrue(model.isEmpty)
|
|
||||||
assertTrue(model.isLibraryEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `home model ignores open tabs for empty state when tabs are disabled`() {
|
|
||||||
val tab = recentFile("tab")
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
isTabsEnabled = false,
|
|
||||||
openTabs = listOf(tab),
|
|
||||||
recentFiles = emptyList()
|
|
||||||
).toHomeScreenModel()
|
|
||||||
|
|
||||||
assertTrue(model.isEmpty)
|
|
||||||
assertEquals(listOf(tab), model.openTabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `home model exposes contextual selection and device limit state`() {
|
|
||||||
val selected = recentFile("selected")
|
|
||||||
val deviceState = DeviceLimitReachedState(isLimitReached = true)
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
recentFiles = listOf(selected),
|
|
||||||
contextualActionItems = setOf(selected),
|
|
||||||
deviceLimitState = deviceState
|
|
||||||
).toHomeScreenModel()
|
|
||||||
|
|
||||||
assertTrue(model.isContextualModeActive)
|
|
||||||
assertEquals(setOf(selected), model.selectedItems)
|
|
||||||
assertEquals(deviceState, model.deviceLimitState)
|
|
||||||
assertFalse(model.isEmpty)
|
|
||||||
assertFalse(model.isLibraryEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `library model exposes contextual and shelf selection state`() {
|
|
||||||
val folderBook = recentFile("folder", sourceFolderUri = "content://folder")
|
|
||||||
val shelf = Shelf(
|
|
||||||
id = "manual",
|
|
||||||
name = "Manual",
|
|
||||||
type = ShelfType.MANUAL,
|
|
||||||
books = listOf(folderBook)
|
|
||||||
)
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
contextualActionItems = setOf(folderBook),
|
|
||||||
contextualActionShelfIds = setOf(shelf.id),
|
|
||||||
sortOrder = SortOrder.TITLE_ASC,
|
|
||||||
shelves = listOf(shelf),
|
|
||||||
rawLibraryFiles = listOf(folderBook),
|
|
||||||
searchQuery = "folder",
|
|
||||||
isSearchActive = true
|
|
||||||
).toLibraryScreenModel()
|
|
||||||
|
|
||||||
assertTrue(model.isContextualModeActive)
|
|
||||||
assertTrue(model.isShelfContextualModeActive)
|
|
||||||
assertTrue(model.containsFolderItemsInSelection)
|
|
||||||
assertEquals(setOf(folderBook), model.selectedItems)
|
|
||||||
assertEquals(setOf(shelf.id), model.selectedShelves)
|
|
||||||
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
|
|
||||||
assertEquals("folder", model.searchQuery)
|
|
||||||
assertTrue(model.isSearchActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `library model reports inactive contextual states for normal browsing`() {
|
|
||||||
val book = recentFile("book")
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
allRecentFiles = listOf(book),
|
|
||||||
rawLibraryFiles = listOf(book),
|
|
||||||
sortOrder = SortOrder.RECENT
|
|
||||||
).toLibraryScreenModel()
|
|
||||||
|
|
||||||
assertFalse(model.isContextualModeActive)
|
|
||||||
assertFalse(model.isShelfContextualModeActive)
|
|
||||||
assertFalse(model.containsFolderItemsInSelection)
|
|
||||||
assertTrue(model.selectedItems.isEmpty())
|
|
||||||
assertTrue(model.selectedShelves.isEmpty())
|
|
||||||
assertEquals(listOf(book), model.rawLibraryFiles)
|
|
||||||
assertEquals(SortOrder.RECENT, model.sortOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `library model distinguishes folder and non-folder selections`() {
|
|
||||||
val localBook = recentFile("local")
|
|
||||||
|
|
||||||
val model = ReaderScreenState(
|
|
||||||
contextualActionItems = setOf(localBook),
|
|
||||||
rawLibraryFiles = listOf(localBook)
|
|
||||||
).toLibraryScreenModel()
|
|
||||||
|
|
||||||
assertTrue(model.isContextualModeActive)
|
|
||||||
assertFalse(model.containsFolderItemsInSelection)
|
|
||||||
assertEquals(setOf(localBook), model.selectedItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recentFile(
|
|
||||||
id: String,
|
|
||||||
isRecent: Boolean = true,
|
|
||||||
sourceFolderUri: String? = null
|
|
||||||
) = RecentFileItem(
|
|
||||||
bookId = id,
|
|
||||||
uriString = "content://$id",
|
|
||||||
type = FileType.EPUB,
|
|
||||||
displayName = "$id.epub",
|
|
||||||
timestamp = 1L,
|
|
||||||
isRecent = isRecent,
|
|
||||||
sourceFolderUri = sourceFolderUri
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PurchaseAccountObfuscatorTest {
|
||||||
|
@Test
|
||||||
|
fun `obfuscated account id is stable and safe for billing`() {
|
||||||
|
val accountId = PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123")
|
||||||
|
|
||||||
|
assertTrue(accountId.startsWith("firebase_"))
|
||||||
|
assertFalse(accountId.contains("="))
|
||||||
|
assertEquals(accountId, PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `purchase token hash matches worker format`() {
|
||||||
|
val token = "jjjnbecgjekfeigbnagcheee.AO-J1OzOurukZmfLAyu6EdPlEvLIyehyOLYajYbGlEK3knhjN4nGe-BLgjXVrSCfRFocGJ5Wc8VcLazRLZxHTdgiUQ5zULRyoQ"
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"sha256_KBe2Ev9nqOx9PMypxP3AlwDgm4E-KIa-i5Eenr1QPF8",
|
||||||
|
PurchaseAccountObfuscator.purchaseTokenHash(token)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReaderScreenOrientationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `screen orientation defaults to follow system and falls back for invalid ids`() {
|
||||||
|
val defaultContext = contextWithPrefs(InMemorySharedPreferences())
|
||||||
|
val invalidContext = contextWithPrefs(
|
||||||
|
InMemorySharedPreferences("reader_screen_orientation_mode" to Int.MIN_VALUE)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(defaultContext))
|
||||||
|
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(invalidContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `screen orientation mode saves loads and maps to activity requested orientation`() {
|
||||||
|
val context = contextWithPrefs(InMemorySharedPreferences())
|
||||||
|
|
||||||
|
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.LANDSCAPE)
|
||||||
|
assertEquals(ReaderScreenOrientationMode.LANDSCAPE, loadReaderScreenOrientationMode(context))
|
||||||
|
|
||||||
|
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.PORTRAIT)
|
||||||
|
assertEquals(ReaderScreenOrientationMode.PORTRAIT, loadReaderScreenOrientationMode(context))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED,
|
||||||
|
ReaderScreenOrientationMode.FOLLOW_SYSTEM.toRequestedOrientation()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT,
|
||||||
|
ReaderScreenOrientationMode.PORTRAIT.toRequestedOrientation()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE,
|
||||||
|
ReaderScreenOrientationMode.LANDSCAPE.toRequestedOrientation()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `right to left pagination is separate for pdf and epub`() {
|
||||||
|
val context = contextWithPrefs(InMemorySharedPreferences())
|
||||||
|
|
||||||
|
assertEquals(false, loadPdfRightToLeftPagination(context))
|
||||||
|
assertEquals(false, loadEpubRightToLeftPagination(context))
|
||||||
|
|
||||||
|
savePdfRightToLeftPagination(context, true)
|
||||||
|
assertEquals(true, loadPdfRightToLeftPagination(context))
|
||||||
|
assertEquals(false, loadEpubRightToLeftPagination(context))
|
||||||
|
|
||||||
|
saveEpubRightToLeftPagination(context, true)
|
||||||
|
assertEquals(true, loadPdfRightToLeftPagination(context))
|
||||||
|
assertEquals(true, loadEpubRightToLeftPagination(context))
|
||||||
|
|
||||||
|
savePdfRightToLeftPagination(context, false)
|
||||||
|
assertEquals(false, loadPdfRightToLeftPagination(context))
|
||||||
|
assertEquals(true, loadEpubRightToLeftPagination(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contextWithPrefs(prefs: SharedPreferences): Context {
|
||||||
|
val context = mockk<Context>()
|
||||||
|
every { context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) } returns prefs
|
||||||
|
every { context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) } returns prefs
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InMemorySharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
|
||||||
|
private val values = initial.toMap().toMutableMap()
|
||||||
|
|
||||||
|
override fun getAll(): MutableMap<String, *> = values
|
||||||
|
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
|
||||||
|
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
|
||||||
|
val value = values[key] as? Set<*> ?: return defValues
|
||||||
|
return value.filterIsInstance<String>().toMutableSet()
|
||||||
|
}
|
||||||
|
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
|
||||||
|
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
|
||||||
|
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
|
||||||
|
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
|
||||||
|
override fun contains(key: String?): Boolean = values.containsKey(key)
|
||||||
|
override fun edit(): SharedPreferences.Editor = Editor()
|
||||||
|
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||||
|
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||||
|
|
||||||
|
private inner class Editor : SharedPreferences.Editor {
|
||||||
|
private val pending = mutableMapOf<String, Any?>()
|
||||||
|
private var clearRequested = false
|
||||||
|
|
||||||
|
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
|
||||||
|
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
|
||||||
|
applyPut(key, values?.toSet())
|
||||||
|
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
|
||||||
|
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
|
||||||
|
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
|
||||||
|
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
|
||||||
|
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
|
||||||
|
override fun clear(): SharedPreferences.Editor {
|
||||||
|
clearRequested = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
override fun commit(): Boolean {
|
||||||
|
flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun apply() = flush()
|
||||||
|
|
||||||
|
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
|
||||||
|
if (key != null) pending[key] = value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flush() {
|
||||||
|
if (clearRequested) values.clear()
|
||||||
|
pending.forEach { (key, value) ->
|
||||||
|
if (value == null) values.remove(key) else values[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt
Normal file
155
app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.shared.FileType as SharedFileType
|
||||||
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||||
|
import com.aryan.reader.shared.ShelfType as SharedShelfType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SharedModelMappersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `book mapper preserves android-only fields when shared projection maps back by id`() {
|
||||||
|
val tag = TagEntity(id = "tag", name = "Favorite", color = 0xFFAA00AA.toInt(), createdAt = 10L)
|
||||||
|
val original = recentFile(
|
||||||
|
id = "book",
|
||||||
|
type = FileType.PDF,
|
||||||
|
displayName = "Original.pdf",
|
||||||
|
customName = "Custom name",
|
||||||
|
isAvailable = false,
|
||||||
|
bookmarksJson = """[{"page":2}]""",
|
||||||
|
sourceFolderUri = "content://folder",
|
||||||
|
tags = listOf(tag)
|
||||||
|
)
|
||||||
|
|
||||||
|
val shared = original.toSharedBookItem()
|
||||||
|
val mapped = shared.toRecentFileItem(
|
||||||
|
androidBooksById = mapOf(original.bookId to original),
|
||||||
|
tagEntitiesById = mapOf(tag.id to tag)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Custom name", shared.displayName)
|
||||||
|
assertEquals(original.uriString, mapped.uriString)
|
||||||
|
assertEquals(original.displayName, mapped.displayName)
|
||||||
|
assertEquals(original.customName, mapped.customName)
|
||||||
|
assertEquals(original.bookmarksJson, mapped.bookmarksJson)
|
||||||
|
assertEquals(original.sourceFolderUri, mapped.sourceFolderUri)
|
||||||
|
assertFalse(mapped.isAvailable)
|
||||||
|
assertEquals(listOf(tag), mapped.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shared projection state maps shelves tabs selections and tags back to android state`() {
|
||||||
|
val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L)
|
||||||
|
val book = recentFile("book", tags = listOf(tag))
|
||||||
|
val sharedBook = book.toSharedBookItem()
|
||||||
|
val sharedShelf = SharedShelf(
|
||||||
|
id = "manual",
|
||||||
|
name = "Manual",
|
||||||
|
type = SharedShelfType.MANUAL,
|
||||||
|
books = listOf(sharedBook),
|
||||||
|
directBooks = listOf(sharedBook)
|
||||||
|
)
|
||||||
|
val projected = SharedReaderScreenState(
|
||||||
|
recentBooks = listOf(sharedBook),
|
||||||
|
libraryBooks = listOf(sharedBook),
|
||||||
|
rawLibraryBooks = listOf(sharedBook),
|
||||||
|
selectedBookIds = setOf("book", "missing"),
|
||||||
|
selectedShelfIds = setOf("manual"),
|
||||||
|
shelves = listOf(sharedShelf),
|
||||||
|
openTabs = listOf(sharedBook),
|
||||||
|
openTabIds = listOf("book"),
|
||||||
|
activeTabBookId = "book",
|
||||||
|
booksAvailableForAdding = listOf(sharedBook),
|
||||||
|
allTags = listOf(tag.toSharedTag())
|
||||||
|
)
|
||||||
|
|
||||||
|
val android = projected.toAndroidReaderScreenState(
|
||||||
|
base = ReaderScreenState(contextualActionItems = setOf(recentFile("missing"))),
|
||||||
|
androidBooksById = mapOf(book.bookId to book),
|
||||||
|
tagEntitiesById = mapOf(tag.id to tag)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("book"), android.recentFiles.ids())
|
||||||
|
assertEquals(listOf("book"), android.allRecentFiles.ids())
|
||||||
|
assertEquals(listOf("book"), android.rawLibraryFiles.ids())
|
||||||
|
assertEquals(setOf("book"), android.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
|
||||||
|
assertEquals(setOf("manual"), android.contextualActionShelfIds)
|
||||||
|
assertEquals(listOf("manual"), android.shelves.map { it.id })
|
||||||
|
assertEquals(listOf("book"), android.shelves.single().books.ids())
|
||||||
|
assertEquals(listOf("book"), android.openTabs.ids())
|
||||||
|
assertEquals(listOf("book"), android.openTabIds)
|
||||||
|
assertEquals("book", android.activeTabBookId)
|
||||||
|
assertEquals(listOf("book"), android.booksAvailableForAdding.ids())
|
||||||
|
assertEquals(listOf(tag), android.allTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enum filter and folder mappers round trip between android and shared`() {
|
||||||
|
val filters = LibraryFilters(
|
||||||
|
fileTypes = setOf(FileType.PDF, FileType.EPUB),
|
||||||
|
sourceFolders = setOf("IN_APP_STORAGE", "content://folder"),
|
||||||
|
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||||
|
tagIds = setOf("tag")
|
||||||
|
)
|
||||||
|
val folder = SyncedFolder(
|
||||||
|
uriString = "content://folder",
|
||||||
|
name = "Folder",
|
||||||
|
lastScanTime = 42L,
|
||||||
|
allowedFileTypes = setOf(FileType.PDF, FileType.CBZ)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FileType.PDF, SharedFileType.PDF.toAndroidFileType())
|
||||||
|
assertEquals(SharedFileType.CBZ, FileType.CBZ.toSharedFileType())
|
||||||
|
assertSame(FileType.UNKNOWN, SharedFileType.UNKNOWN.toAndroidFileType())
|
||||||
|
assertSame(filters, filters.toSharedLibraryFilters())
|
||||||
|
assertSame(folder, folder.toSharedSyncedFolder())
|
||||||
|
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
|
||||||
|
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
|
||||||
|
assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES)
|
||||||
|
assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tag resolver attaches database tags before shared projection`() {
|
||||||
|
val tag = TagEntity(id = "tag", name = "Reference", createdAt = 1L)
|
||||||
|
val files = listOf(recentFile("book"))
|
||||||
|
|
||||||
|
val tagged = files.withResolvedTags(
|
||||||
|
dbTags = listOf(tag),
|
||||||
|
tagRefs = listOf(BookTagCrossRef(bookId = "book", tagId = "tag"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(tag), tagged.single().tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recentFile(
|
||||||
|
id: String,
|
||||||
|
type: FileType = FileType.EPUB,
|
||||||
|
displayName: String = "$id.${type.name.lowercase()}",
|
||||||
|
customName: String? = null,
|
||||||
|
isAvailable: Boolean = true,
|
||||||
|
bookmarksJson: String? = null,
|
||||||
|
sourceFolderUri: String? = null,
|
||||||
|
tags: List<TagEntity> = emptyList()
|
||||||
|
) = RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = "content://$id",
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = 1L,
|
||||||
|
isAvailable = isAvailable,
|
||||||
|
bookmarksJson = bookmarksJson,
|
||||||
|
sourceFolderUri = sourceFolderUri,
|
||||||
|
customName = customName,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<RecentFileItem>.ids() = map { it.bookId }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.aryan.reader.data
|
||||||
|
|
||||||
|
import com.aryan.reader.FileType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FileTypeConverterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `room converter stores enum names and preserves unknown fallback`() {
|
||||||
|
val converter = FileTypeConverter()
|
||||||
|
|
||||||
|
assertEquals("PDF", converter.fromFileType(FileType.PDF))
|
||||||
|
assertEquals(FileType.PDF, converter.toFileType("PDF"))
|
||||||
|
assertEquals("UNKNOWN", converter.fromFileType(FileType.UNKNOWN))
|
||||||
|
assertEquals(FileType.UNKNOWN, converter.toFileType("UNKNOWN"))
|
||||||
|
assertNull(converter.fromFileType(null))
|
||||||
|
assertNull(converter.toFileType(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,15 @@ class FolderBookMetadataTest {
|
||||||
|
|
||||||
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
|
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
|
||||||
|
|
||||||
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded)
|
assertEquals(
|
||||||
|
metadata.copy(
|
||||||
|
title = null,
|
||||||
|
author = null,
|
||||||
|
lastPage = null,
|
||||||
|
locatorCharOffset = null
|
||||||
|
),
|
||||||
|
decoded
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -78,8 +86,8 @@ class FolderBookMetadataTest {
|
||||||
|
|
||||||
assertEquals("book-2", item.bookId)
|
assertEquals("book-2", item.bookId)
|
||||||
assertEquals(FileType.EPUB, item.type)
|
assertEquals(FileType.EPUB, item.type)
|
||||||
assertEquals("Remote Title", item.title)
|
assertEquals("Remote", item.title)
|
||||||
assertEquals("Author", item.author)
|
assertNull(item.author)
|
||||||
assertEquals(12, item.lastPage)
|
assertEquals(12, item.lastPage)
|
||||||
assertEquals(7, item.locatorBlockIndex)
|
assertEquals(7, item.locatorBlockIndex)
|
||||||
assertEquals(8, item.locatorCharOffset)
|
assertEquals(8, item.locatorCharOffset)
|
||||||
|
|
@ -87,4 +95,34 @@ class FolderBookMetadataTest {
|
||||||
assertEquals("Shelf Name", item.customName)
|
assertEquals("Shelf Name", item.customName)
|
||||||
assertEquals("highlights", item.highlightsJson)
|
assertEquals("highlights", item.highlightsJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toRecentFileItem preserves explicit unknown file type`() {
|
||||||
|
val metadata = FolderBookMetadata(
|
||||||
|
bookId = "book-3",
|
||||||
|
title = null,
|
||||||
|
author = null,
|
||||||
|
displayName = "Remote.bin",
|
||||||
|
type = "UNKNOWN",
|
||||||
|
lastChapterIndex = null,
|
||||||
|
lastPage = null,
|
||||||
|
lastPositionCfi = null,
|
||||||
|
progressPercentage = 0f,
|
||||||
|
isRecent = false,
|
||||||
|
lastModifiedTimestamp = 500L,
|
||||||
|
bookmarksJson = null,
|
||||||
|
locatorBlockIndex = null,
|
||||||
|
locatorCharOffset = null,
|
||||||
|
customName = null,
|
||||||
|
highlightsJson = null
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = metadata.toRecentFileItem(
|
||||||
|
uriString = "content://book",
|
||||||
|
coverPath = null,
|
||||||
|
sourceFolderUri = "content://folder"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FileType.UNKNOWN, item.type)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
package com.aryan.reader.data
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.aryan.reader.FileType
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class RecentFileDaoMetadataExtractionTest {
|
||||||
|
|
||||||
|
private lateinit var db: AppDatabase
|
||||||
|
private lateinit var dao: RecentFileDao
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(
|
||||||
|
RuntimeEnvironment.getApplication(),
|
||||||
|
AppDatabase::class.java
|
||||||
|
).allowMainThreadQueries().build()
|
||||||
|
dao = db.recentFileDao()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cover-only ebook metadata candidate is marked attempted after no cover is found`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity(
|
||||||
|
folderTextMetadataParsed = true,
|
||||||
|
folderCoverMetadataParsed = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, dao.countFolderBooksNeedingTextMetadata("content://folder"))
|
||||||
|
|
||||||
|
dao.updateExtractedMetadata(
|
||||||
|
bookId = "book-1",
|
||||||
|
coverImagePath = null,
|
||||||
|
title = null,
|
||||||
|
author = null,
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
fileSize = 0L,
|
||||||
|
fileContentModifiedTimestamp = 0L,
|
||||||
|
textMetadataParsed = false,
|
||||||
|
coverMetadataParsed = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = dao.getFileByBookId("book-1")!!
|
||||||
|
assertFalse(saved.coverImagePath?.isNotBlank() == true)
|
||||||
|
assertTrue(saved.folderCoverMetadataParsed)
|
||||||
|
assertEquals(0, dao.countFolderBooksNeedingTextMetadata("content://folder"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `metadata candidate query respects batch limit`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-1", timestamp = 1_000L))
|
||||||
|
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-2", timestamp = 2_000L))
|
||||||
|
|
||||||
|
val pending = dao.getFolderBooksNeedingTextMetadata("content://folder", limit = 1)
|
||||||
|
|
||||||
|
assertEquals(1, pending.size)
|
||||||
|
assertEquals("book-2", pending.single().bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `metadata extraction does not replace user edited metadata`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity().copy(
|
||||||
|
title = "Edited title",
|
||||||
|
author = "Edited author",
|
||||||
|
originalTitle = "Original title",
|
||||||
|
originalAuthor = "Original author"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.updateExtractedMetadata(
|
||||||
|
bookId = "book-1",
|
||||||
|
coverImagePath = null,
|
||||||
|
title = "Extracted title",
|
||||||
|
author = "Extracted author",
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
fileSize = 0L,
|
||||||
|
fileContentModifiedTimestamp = 0L,
|
||||||
|
textMetadataParsed = true,
|
||||||
|
coverMetadataParsed = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = dao.getFileByBookId("book-1")!!
|
||||||
|
assertEquals("Edited title", saved.title)
|
||||||
|
assertEquals("Edited author", saved.author)
|
||||||
|
assertEquals("Original title", saved.originalTitle)
|
||||||
|
assertEquals("Original author", saved.originalAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `metadata extraction promotes extracted values before user edits`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity().copy(
|
||||||
|
title = "book-1.epub",
|
||||||
|
author = null,
|
||||||
|
originalTitle = "book-1.epub",
|
||||||
|
originalAuthor = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.updateExtractedMetadata(
|
||||||
|
bookId = "book-1",
|
||||||
|
coverImagePath = null,
|
||||||
|
title = "Extracted title",
|
||||||
|
author = "Extracted author",
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
fileSize = 0L,
|
||||||
|
fileContentModifiedTimestamp = 0L,
|
||||||
|
textMetadataParsed = true,
|
||||||
|
coverMetadataParsed = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = dao.getFileByBookId("book-1")!!
|
||||||
|
assertEquals("Extracted title", saved.title)
|
||||||
|
assertEquals("Extracted author", saved.author)
|
||||||
|
assertEquals("Extracted title", saved.originalTitle)
|
||||||
|
assertEquals("Extracted author", saved.originalAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restore original metadata restores snapshot and clears display override`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity().copy(
|
||||||
|
title = "Edited title",
|
||||||
|
author = "Edited author",
|
||||||
|
seriesName = "Edited series",
|
||||||
|
seriesIndex = 2.0,
|
||||||
|
description = "Edited summary",
|
||||||
|
customName = "Edited display",
|
||||||
|
originalTitle = "Original title",
|
||||||
|
originalAuthor = "Original author",
|
||||||
|
originalSeriesName = "Original series",
|
||||||
|
originalSeriesIndex = 1.0,
|
||||||
|
originalDescription = "Original summary"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.restoreOriginalMetadata("book-1", fileSize = 0L, fileContentModifiedTimestamp = 0L, timestamp = 9_000L)
|
||||||
|
|
||||||
|
val saved = dao.getFileByBookId("book-1")!!
|
||||||
|
assertEquals("Original title", saved.title)
|
||||||
|
assertEquals("Original author", saved.author)
|
||||||
|
assertEquals("Original series", saved.seriesName)
|
||||||
|
assertEquals(1.0, saved.seriesIndex)
|
||||||
|
assertEquals("Original summary", saved.description)
|
||||||
|
assertNull(saved.customName)
|
||||||
|
assertEquals(9_000L, saved.lastModifiedTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual metadata update seeds missing original snapshot from previous values`() = runTest {
|
||||||
|
dao.insertOrUpdateFile(
|
||||||
|
recentFileEntity().copy(
|
||||||
|
title = "Existing title",
|
||||||
|
author = "Existing author",
|
||||||
|
customName = "Display override",
|
||||||
|
originalTitle = null,
|
||||||
|
originalAuthor = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.updateUserEditableMetadata(
|
||||||
|
bookId = "book-1",
|
||||||
|
title = "Edited title",
|
||||||
|
author = "Edited author",
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
fileSize = 0L,
|
||||||
|
fileContentModifiedTimestamp = 0L,
|
||||||
|
timestamp = 5_000L
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = dao.getFileByBookId("book-1")!!
|
||||||
|
assertEquals("Edited title", saved.title)
|
||||||
|
assertEquals("Edited author", saved.author)
|
||||||
|
assertEquals("Existing title", saved.originalTitle)
|
||||||
|
assertEquals("Existing author", saved.originalAuthor)
|
||||||
|
assertNull(saved.customName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recentFileEntity(
|
||||||
|
bookId: String = "book-1",
|
||||||
|
timestamp: Long = 1_000L,
|
||||||
|
folderTextMetadataParsed: Boolean = false,
|
||||||
|
folderCoverMetadataParsed: Boolean = false
|
||||||
|
): RecentFileEntity {
|
||||||
|
return RecentFileEntity(
|
||||||
|
bookId = bookId,
|
||||||
|
uriString = "content://books/$bookId",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "$bookId.epub",
|
||||||
|
timestamp = timestamp,
|
||||||
|
coverImagePath = null,
|
||||||
|
title = "One",
|
||||||
|
author = "Author",
|
||||||
|
lastChapterIndex = null,
|
||||||
|
lastPage = null,
|
||||||
|
lastPositionCfi = null,
|
||||||
|
progressPercentage = null,
|
||||||
|
isRecent = false,
|
||||||
|
isAvailable = true,
|
||||||
|
lastModifiedTimestamp = timestamp,
|
||||||
|
isDeleted = false,
|
||||||
|
locatorBlockIndex = null,
|
||||||
|
locatorCharOffset = null,
|
||||||
|
bookmarks = null,
|
||||||
|
sourceFolderUri = "content://folder",
|
||||||
|
isReflowPreferred = false,
|
||||||
|
customName = null,
|
||||||
|
highlights = null,
|
||||||
|
fileSize = 123L,
|
||||||
|
fileContentModifiedTimestamp = 1_234L,
|
||||||
|
seriesName = null,
|
||||||
|
seriesIndex = null,
|
||||||
|
description = null,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = folderCoverMetadataParsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ class RecentFileItemReadingPositionMappingTest {
|
||||||
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
|
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
|
||||||
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||||
|
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -30,6 +31,59 @@ class RecentFileItemReadingPositionMappingTest {
|
||||||
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
|
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
|
||||||
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||||
|
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud metadata mapping preserves non epub display rename as custom name`() {
|
||||||
|
val item = recentFileItem().copy(
|
||||||
|
type = FileType.PDF,
|
||||||
|
customName = "Reader Display Name"
|
||||||
|
)
|
||||||
|
|
||||||
|
val roundTripped = item.toBookMetadata().toRecentFileItem()
|
||||||
|
|
||||||
|
assertEquals("Reader Display Name", roundTripped.customName)
|
||||||
|
assertEquals("One.epub", roundTripped.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recent file entity mapping preserves original metadata snapshot`() {
|
||||||
|
val item = recentFileItem().copy(
|
||||||
|
title = "Edited One",
|
||||||
|
author = "Edited Author",
|
||||||
|
seriesName = "Edited Series",
|
||||||
|
seriesIndex = 2.0,
|
||||||
|
description = "Edited summary",
|
||||||
|
originalTitle = "Original One",
|
||||||
|
originalAuthor = "Original Author",
|
||||||
|
originalSeriesName = "Original Series",
|
||||||
|
originalSeriesIndex = 1.0,
|
||||||
|
originalDescription = "Original summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
|
||||||
|
|
||||||
|
assertEquals("Original One", roundTripped.originalTitle)
|
||||||
|
assertEquals("Original Author", roundTripped.originalAuthor)
|
||||||
|
assertEquals("Original Series", roundTripped.originalSeriesName)
|
||||||
|
assertEquals(1.0, roundTripped.originalSeriesIndex)
|
||||||
|
assertEquals("Original summary", roundTripped.originalDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recent file entity mapping seeds original metadata when first stored`() {
|
||||||
|
val entity = recentFileItem().copy(
|
||||||
|
seriesName = "Series",
|
||||||
|
seriesIndex = 1.0,
|
||||||
|
description = "Summary"
|
||||||
|
).toRecentFileEntity()
|
||||||
|
|
||||||
|
assertEquals("One", entity.originalTitle)
|
||||||
|
assertEquals("Author", entity.originalAuthor)
|
||||||
|
assertEquals("Series", entity.originalSeriesName)
|
||||||
|
assertEquals(1.0, entity.originalSeriesIndex)
|
||||||
|
assertEquals("Summary", entity.originalDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun recentFileItem(): RecentFileItem {
|
private fun recentFileItem(): RecentFileItem {
|
||||||
|
|
@ -47,6 +101,7 @@ class RecentFileItemReadingPositionMappingTest {
|
||||||
locatorCharOffset = 88,
|
locatorCharOffset = 88,
|
||||||
progressPercentage = 61.5f,
|
progressPercentage = 61.5f,
|
||||||
lastModifiedTimestamp = 2_000L,
|
lastModifiedTimestamp = 2_000L,
|
||||||
|
fileContentModifiedTimestamp = 3_000L,
|
||||||
bookmarksJson = """[{"cfi":"/4/2"}]""",
|
bookmarksJson = """[{"cfi":"/4/2"}]""",
|
||||||
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
|
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import io.mockk.unmockkObject
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -113,14 +115,82 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
||||||
assertEquals(82f, inserted.captured.progressPercentage)
|
assertEquals(82f, inserted.captured.progressPercentage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun existingEntity(): RecentFileEntity {
|
@Test
|
||||||
|
fun `addRecentFile keeps edited embedded epub metadata when cached parser returns original metadata`() = runTest {
|
||||||
|
val inserted = slot<RecentFileEntity>()
|
||||||
|
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy(
|
||||||
|
author = "Edited Author",
|
||||||
|
originalAuthor = "Author",
|
||||||
|
fileContentModifiedTimestamp = 5_000L
|
||||||
|
)
|
||||||
|
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
|
||||||
|
|
||||||
|
repository.addRecentFile(
|
||||||
|
RecentFileItem(
|
||||||
|
bookId = "book-1",
|
||||||
|
uriString = "content://new",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "New.epub",
|
||||||
|
timestamp = 2_000L,
|
||||||
|
title = "Old",
|
||||||
|
author = "Author",
|
||||||
|
fileContentModifiedTimestamp = 5_000L,
|
||||||
|
isRecent = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Edited Author", inserted.captured.author)
|
||||||
|
assertEquals("Author", inserted.captured.originalAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addRecentFile clears extracted metadata when folder file size changes`() = runTest {
|
||||||
|
val inserted = slot<RecentFileEntity>()
|
||||||
|
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity(
|
||||||
|
fileSize = 123L,
|
||||||
|
folderTextMetadataParsed = true,
|
||||||
|
folderCoverMetadataParsed = true,
|
||||||
|
coverImagePath = "/covers/old.png"
|
||||||
|
)
|
||||||
|
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
|
||||||
|
|
||||||
|
repository.addRecentFile(
|
||||||
|
RecentFileItem(
|
||||||
|
bookId = "book-1",
|
||||||
|
uriString = "content://new",
|
||||||
|
type = FileType.EPUB,
|
||||||
|
displayName = "New.epub",
|
||||||
|
timestamp = 2_000L,
|
||||||
|
sourceFolderUri = "content://folder",
|
||||||
|
fileSize = 456L,
|
||||||
|
isRecent = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(456L, inserted.captured.fileSize)
|
||||||
|
assertNull(inserted.captured.coverImagePath)
|
||||||
|
assertEquals("New", inserted.captured.title)
|
||||||
|
assertNull(inserted.captured.author)
|
||||||
|
assertNull(inserted.captured.seriesName)
|
||||||
|
assertNull(inserted.captured.description)
|
||||||
|
assertNull(inserted.captured.originalTitle)
|
||||||
|
assertFalse(inserted.captured.folderTextMetadataParsed)
|
||||||
|
assertFalse(inserted.captured.folderCoverMetadataParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun existingEntity(
|
||||||
|
fileSize: Long = 123L,
|
||||||
|
folderTextMetadataParsed: Boolean = true,
|
||||||
|
folderCoverMetadataParsed: Boolean = false,
|
||||||
|
coverImagePath: String? = "/covers/old.png"
|
||||||
|
): RecentFileEntity {
|
||||||
return RecentFileEntity(
|
return RecentFileEntity(
|
||||||
bookId = "book-1",
|
bookId = "book-1",
|
||||||
uriString = "content://old",
|
uriString = "content://old",
|
||||||
type = FileType.EPUB,
|
type = FileType.EPUB,
|
||||||
displayName = "Old.epub",
|
displayName = "Old.epub",
|
||||||
timestamp = 1_000L,
|
timestamp = 1_000L,
|
||||||
coverImagePath = "/covers/old.png",
|
coverImagePath = coverImagePath,
|
||||||
title = "Old",
|
title = "Old",
|
||||||
author = "Author",
|
author = "Author",
|
||||||
lastChapterIndex = 6,
|
lastChapterIndex = 6,
|
||||||
|
|
@ -138,11 +208,12 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
||||||
isReflowPreferred = false,
|
isReflowPreferred = false,
|
||||||
customName = "Custom",
|
customName = "Custom",
|
||||||
highlights = "highlights",
|
highlights = "highlights",
|
||||||
fileSize = 123L,
|
fileSize = fileSize,
|
||||||
seriesName = "Series",
|
seriesName = "Series",
|
||||||
seriesIndex = 1.0,
|
seriesIndex = 1.0,
|
||||||
description = "Description",
|
description = "Description",
|
||||||
folderTextMetadataParsed = true
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = folderCoverMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
class EpubParserUnitTest {
|
class EpubParserUnitTest {
|
||||||
|
|
@ -103,6 +104,43 @@ class EpubParserUnitTest {
|
||||||
assertTrue(extractionDir.list().isNullOrEmpty())
|
assertTrue(extractionDir.list().isNullOrEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
||||||
|
val cacheDir = temp.newFolder("cache-metadata-stream")
|
||||||
|
val extractionDir = temp.newFolder("extract-metadata-stream")
|
||||||
|
val parser = EpubParser(contextWithCache(cacheDir))
|
||||||
|
val imageBytes = ByteArray(2 * 1024 * 1024) { 7 }
|
||||||
|
val zipFileOnDisk = File(temp.root, "metadata-stream.epub")
|
||||||
|
zipFileOnDisk.writeBytes(
|
||||||
|
zipBinaryBytes(
|
||||||
|
"META-INF/container.xml" to """
|
||||||
|
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
"OEBPS/content.opf" to """
|
||||||
|
<package>
|
||||||
|
<metadata />
|
||||||
|
<manifest>
|
||||||
|
<item id="cover" href="images/cover.jpg" media-type="image/jpeg"/>
|
||||||
|
</manifest>
|
||||||
|
<spine />
|
||||||
|
</package>
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
"OEBPS/images/cover.jpg" to imageBytes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val files = parser.extractEpubContents(
|
||||||
|
zipFile = ZipFile(zipFileOnDisk),
|
||||||
|
extractionDir = extractionDir,
|
||||||
|
parseContent = false,
|
||||||
|
extractImagesForMetadata = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(files["META-INF/container.xml"]!!.data.isNotEmpty())
|
||||||
|
assertEquals(0, files["OEBPS/images/cover.jpg"]!!.data.size)
|
||||||
|
assertEquals(imageBytes.size.toLong(), File(extractionDir, "OEBPS/images/cover.jpg").length())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
|
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
|
||||||
val cacheDir = temp.newFolder("cache-warm-open")
|
val cacheDir = temp.newFolder("cache-warm-open")
|
||||||
|
|
@ -129,6 +167,35 @@ class EpubParserUnitTest {
|
||||||
assertTrue(File(activeDir, "sentinel.txt").isFile)
|
assertTrue(File(activeDir, "sentinel.txt").isFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createEpubBook invalidates active extraction cache when source fingerprint changes`() = runTest {
|
||||||
|
val cacheDir = temp.newFolder("cache-source-change")
|
||||||
|
val context = contextWithCache(cacheDir)
|
||||||
|
val parser = EpubParser(context)
|
||||||
|
|
||||||
|
val first = parser.createEpubBook(
|
||||||
|
inputStream = ByteArrayInputStream(sampleEpubBytes()),
|
||||||
|
bookId = "changed-book",
|
||||||
|
shouldUseToc = true,
|
||||||
|
originalBookNameHint = "changed.epub",
|
||||||
|
sourceFingerprint = "100:1000"
|
||||||
|
)
|
||||||
|
val activeDir = ImportedFileCache.activeBookDir(context, "changed-book")
|
||||||
|
File(activeDir, "sentinel.txt").writeText("old extraction")
|
||||||
|
|
||||||
|
val second = parser.createEpubBook(
|
||||||
|
inputStream = ByteArrayInputStream(sampleEpubBytes(author = "Edited Writer")),
|
||||||
|
bookId = "changed-book",
|
||||||
|
shouldUseToc = true,
|
||||||
|
originalBookNameHint = "changed.epub",
|
||||||
|
sourceFingerprint = "120:2000"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Jane Writer", first.author)
|
||||||
|
assertEquals("Edited Writer", second.author)
|
||||||
|
assertFalse(File(activeDir, "sentinel.txt").isFile)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `metadata only parse does not clear active extracted content`() = runTest {
|
fun `metadata only parse does not clear active extracted content`() = runTest {
|
||||||
val cacheDir = temp.newFolder("cache-metadata-preserve")
|
val cacheDir = temp.newFolder("cache-metadata-preserve")
|
||||||
|
|
@ -325,7 +392,7 @@ class EpubParserUnitTest {
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleEpubBytes(): ByteArray = zipBytes(
|
private fun sampleEpubBytes(author: String = "Jane Writer"): ByteArray = zipBytes(
|
||||||
"META-INF/container.xml" to """
|
"META-INF/container.xml" to """
|
||||||
<container version="1.0">
|
<container version="1.0">
|
||||||
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
|
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
|
||||||
|
|
@ -335,7 +402,7 @@ class EpubParserUnitTest {
|
||||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
<metadata>
|
<metadata>
|
||||||
<dc:title>Sample/Book</dc:title>
|
<dc:title>Sample/Book</dc:title>
|
||||||
<dc:creator>Jane Writer</dc:creator>
|
<dc:creator>$author</dc:creator>
|
||||||
<dc:language>en</dc:language>
|
<dc:language>en</dc:language>
|
||||||
<dc:description>Long description</dc:description>
|
<dc:description>Long description</dc:description>
|
||||||
<meta name="calibre:series" content="Series Name"/>
|
<meta name="calibre:series" content="Series Name"/>
|
||||||
|
|
@ -415,11 +482,15 @@ class EpubParserUnitTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
|
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
|
||||||
|
return zipBinaryBytes(*entries.map { it.first to it.second.toByteArray(Charsets.UTF_8) }.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipBinaryBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
|
||||||
val out = ByteArrayOutputStream()
|
val out = ByteArrayOutputStream()
|
||||||
ZipOutputStream(out).use { zip ->
|
ZipOutputStream(out).use { zip ->
|
||||||
entries.forEach { (name, content) ->
|
entries.forEach { (name, content) ->
|
||||||
zip.putNextEntry(ZipEntry(name))
|
zip.putNextEntry(ZipEntry(name))
|
||||||
zip.write(content.toByteArray(Charsets.UTF_8))
|
zip.write(content)
|
||||||
zip.closeEntry()
|
zip.closeEntry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,5 +196,6 @@ class EpubReaderBridgeAndControlsTest {
|
||||||
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
|
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
|
||||||
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
|
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
|
||||||
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
|
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
|
||||||
|
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,10 @@ class OpdsParserTest {
|
||||||
val acquisitions = listOf(
|
val acquisitions = listOf(
|
||||||
OpdsAcquisition("txt", "text/plain"),
|
OpdsAcquisition("txt", "text/plain"),
|
||||||
OpdsAcquisition("pdf", "application/pdf"),
|
OpdsAcquisition("pdf", "application/pdf"),
|
||||||
|
OpdsAcquisition(
|
||||||
|
"pptx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
),
|
||||||
OpdsAcquisition("epub", "application/epub+zip"),
|
OpdsAcquisition("epub", "application/epub+zip"),
|
||||||
OpdsAcquisition("unknown", "application/octet-stream")
|
OpdsAcquisition("unknown", "application/octet-stream")
|
||||||
)
|
)
|
||||||
|
|
@ -200,9 +204,10 @@ class OpdsParserTest {
|
||||||
navigationUrl = null
|
navigationUrl = null
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("EPUB", acquisitions[2].formatName)
|
assertEquals("EPUB", acquisitions[3].formatName)
|
||||||
|
assertEquals("PPTX", acquisitions[2].formatName)
|
||||||
assertEquals("TXT", acquisitions[0].formatName)
|
assertEquals("TXT", acquisitions[0].formatName)
|
||||||
assertEquals("OCTET-STREAM", acquisitions[3].formatName)
|
assertEquals("OCTET-STREAM", acquisitions[4].formatName)
|
||||||
assertEquals(acquisitions[2], entry.bestAcquisition)
|
assertEquals(acquisitions[3], entry.bestAcquisition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -20,6 +22,14 @@ class CfiUtilsTest {
|
||||||
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
|
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOffsetOrNull only returns explicit numeric offsets`() {
|
||||||
|
assertEquals(13, CfiUtils.getOffsetOrNull("/4/2/6:13"))
|
||||||
|
assertEquals(0, CfiUtils.getOffsetOrNull("/4/2/6:0"))
|
||||||
|
assertNull(CfiUtils.getOffsetOrNull("/4/2/6"))
|
||||||
|
assertNull(CfiUtils.getOffsetOrNull("/4/2/6:bad"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `compare sorts numeric cfi paths before character offsets`() {
|
fun `compare sorts numeric cfi paths before character offsets`() {
|
||||||
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
|
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
|
||||||
|
|
@ -27,4 +37,12 @@ class CfiUtilsTest {
|
||||||
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
|
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
|
||||||
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
|
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isPathStrictlyBetween requires a bounded numeric cfi path`() {
|
||||||
|
assertTrue(CfiUtils.isPathStrictlyBetween("/4/4", "/4/2:1", "/4/6:1"))
|
||||||
|
assertFalse(CfiUtils.isPathStrictlyBetween("/4/2", "/4/2:1", "/4/6:1"))
|
||||||
|
assertFalse(CfiUtils.isPathStrictlyBetween("/4/8", "/4/2:1", "/4/6:1"))
|
||||||
|
assertFalse(CfiUtils.isPathStrictlyBetween("/4/nav", "/4/2:1", "/4/6:1"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|
@ -123,6 +126,47 @@ class ContentStylerTest {
|
||||||
assertEquals("li1", first.elementId)
|
assertEquals("li1", first.elementId)
|
||||||
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
|
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
|
||||||
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
|
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
|
||||||
|
assertTrue(first.content.spanStyles.any { range ->
|
||||||
|
range.start <= 0 &&
|
||||||
|
range.end >= 5 &&
|
||||||
|
range.item.color.isSpecified &&
|
||||||
|
range.item.color != Color.Red &&
|
||||||
|
range.item.background.isSpecified &&
|
||||||
|
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runtime theme reapplies visible link style for cached paginated text`() {
|
||||||
|
val linkText = "Cached link"
|
||||||
|
val text = buildAnnotatedString {
|
||||||
|
append(linkText)
|
||||||
|
addStringAnnotation("URL", "https://example.org", 0, linkText.length)
|
||||||
|
}
|
||||||
|
val page = Page(
|
||||||
|
content = listOf(
|
||||||
|
ParagraphBlock(
|
||||||
|
content = text,
|
||||||
|
blockIndex = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val themed = page.applyReaderThemeForDisplay(
|
||||||
|
isDarkTheme = true,
|
||||||
|
themeBackgroundColor = Color(0xFF121212),
|
||||||
|
themeTextColor = Color(0xFFE0E0E0)
|
||||||
|
)
|
||||||
|
val paragraph = themed.content.single() as ParagraphBlock
|
||||||
|
|
||||||
|
assertTrue(paragraph.content.spanStyles.any { range ->
|
||||||
|
range.start == 0 &&
|
||||||
|
range.end == linkText.length &&
|
||||||
|
range.item.color.isSpecified &&
|
||||||
|
range.item.color != Color(0xFFE0E0E0) &&
|
||||||
|
range.item.background.isSpecified &&
|
||||||
|
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import com.aryan.reader.epubreader.HighlightColor
|
||||||
|
import com.aryan.reader.epubreader.UserHighlight
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PaginatedHighlightMappingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single cfi highlight does not leak onto later matching block`() {
|
||||||
|
val block = paragraph(
|
||||||
|
text = "repeat",
|
||||||
|
cfi = "/4/4",
|
||||||
|
startOffset = 20
|
||||||
|
)
|
||||||
|
val highlight = highlight(
|
||||||
|
cfi = "/4/2:0",
|
||||||
|
text = "repeat"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multipart highlight can fill strict intermediate block`() {
|
||||||
|
val block = paragraph(
|
||||||
|
text = "middle",
|
||||||
|
cfi = "/4/4",
|
||||||
|
startOffset = 20
|
||||||
|
)
|
||||||
|
val highlight = highlight(
|
||||||
|
cfi = "/4/2:0|/4/6:10",
|
||||||
|
text = "start middle end"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same path split block outside stored offsets is ignored`() {
|
||||||
|
val block = paragraph(
|
||||||
|
text = "repeat",
|
||||||
|
cfi = "/4/2",
|
||||||
|
startOffset = 20
|
||||||
|
)
|
||||||
|
val highlight = highlight(
|
||||||
|
cfi = "/4/2:0|/4/2:6",
|
||||||
|
text = "repeat"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun paragraph(
|
||||||
|
text: String,
|
||||||
|
cfi: String,
|
||||||
|
startOffset: Int
|
||||||
|
): ParagraphBlock {
|
||||||
|
return ParagraphBlock(
|
||||||
|
content = AnnotatedString(text),
|
||||||
|
cfi = cfi,
|
||||||
|
startCharOffsetInSource = startOffset,
|
||||||
|
endCharOffsetInSource = startOffset + text.length,
|
||||||
|
blockIndex = startOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun highlight(
|
||||||
|
cfi: String,
|
||||||
|
text: String
|
||||||
|
): UserHighlight {
|
||||||
|
return UserHighlight(
|
||||||
|
id = "highlight",
|
||||||
|
cfi = cfi,
|
||||||
|
text = text,
|
||||||
|
color = HighlightColor.YELLOW,
|
||||||
|
chapterIndex = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue